Compare commits

...
114 changed files with 3817 additions and 1136 deletions
+41 -1
View File
@@ -23,16 +23,56 @@ module.exports = {
from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
{
name: "page-templates-own-layout-only",
severity: "error",
from: { path: "^src/presentation/templates" },
to: {
path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
},
},
{
name: "adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" },
},
{
name: "feature-domain-is-framework-neutral",
severity: "error",
from: { path: "^src/features/[^/]+/domain" },
to: {
path: "^(src/(application|presentation|adapters|bootstrap)|src/features/[^/]+/(application|adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-application-does-not-know-runtime",
severity: "error",
from: { path: "^src/features/[^/]+/application" },
to: {
path: "^(src/(presentation|adapters|bootstrap)|src/features/[^/]+/(adapters|presentation)|react|react-dom|@tanstack)",
},
},
{
name: "feature-presentation-does-not-know-outbound-adapters",
severity: "error",
from: { path: "^src/features/[^/]+/presentation" },
to: {
path: "^(src/(adapters|bootstrap)|src/features/[^/]+/adapters|@tanstack)",
},
},
{
name: "feature-adapters-do-not-know-presentation",
severity: "error",
from: { path: "^src/features/[^/]+/adapters" },
to: {
path: "^(src/(presentation|bootstrap)|src/features/[^/]+/presentation)",
},
},
{
name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error",
from: { path: "^src/(domain|application|presentation|contracts|sample)" },
from: { path: "^src/(domain|application|presentation|contracts)" },
to: { path: "^src/adapters" },
},
{
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_DETAIL accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_DETAIL
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_FORM accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_FORM
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -1,7 +1,7 @@
# SAMPLE_RESOURCE_LIST accessibility review
# REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
Route ID: REFERENCE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_STATUS accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_STATUS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+12 -5
View File
@@ -80,7 +80,8 @@
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" }
{ "script": "check:types:fixture:route-runtime", "expect": "fail" },
{ "script": "check:types:fixture:page-action", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -109,9 +110,15 @@
},
"FE-GATE-007": {
"name": "integration",
"steps": [{ "script": "test:integration", "expect": "pass" }],
"steps": [
{ "script": "test:integration", "expect": "pass" },
{ "script": "test:reference-feature", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-007.txt",
"evidence": ["artifacts/tests/integration.xml"],
"evidence": [
"artifacts/tests/integration.xml",
"artifacts/tests/reference-feature.xml"
],
"retentionClass": "merge-cycle"
},
"FE-GATE-008": {
@@ -134,7 +141,7 @@
"artifacts/tests/a11y-manual/EXAMPLES_UI.md",
"artifacts/tests/a11y-manual/EXAMPLES_STATES.md",
"artifacts/tests/a11y-manual/EXAMPLES_AUTH.md",
"artifacts/tests/a11y-manual/SAMPLE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md",
"artifacts/tests/a11y-manual/NOT_FOUND.md",
"artifacts/tests/a11y-manual/report.json"
],
@@ -256,7 +263,7 @@
"retentionClass": "release-coherence"
},
"FE-GATE-020": {
"name": "sample-removal",
"name": "reference-feature-removal",
"steps": [{ "script": "test:sample-removal", "expect": "pass" }],
"logPath": "artifacts/quality/gates/FE-GATE-020.txt",
"evidence": ["artifacts/tests/sample-removal.xml"],
+20 -11
View File
@@ -3,17 +3,20 @@
"registries": [
{
"registryId": "FE-REG-ROUTE",
"path": "src/contracts/routes.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_REGISTRY",
"owner": "feature-routing-navigation-guard-contract",
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"paramsSchema": [null, "NotFoundSplat"],
"searchSchema": [null, "SampleResourceListQuery"],
"paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"sample-resource-list",
"reference-resource-list",
"reference-resource-detail",
"reference-resource-form",
"reference-resource-status",
"none"
],
"errorSurface": [
@@ -26,7 +29,10 @@
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-sample-resources",
"route-reference-resources",
"route-reference-resource-detail",
"route-reference-resource-form",
"route-reference-resource-status",
"route-not-found"
]
},
@@ -50,7 +56,7 @@
},
{
"registryId": "FE-REG-ROUTE-RUNTIME",
"path": "src/contracts/route-runtime-contract.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "ROUTE_RUNTIME_CONTRACT",
"owner": "feature-frontend-routing-release-recovery-runtime",
"requiredFields": [
@@ -66,11 +72,14 @@
"ui-gallery-page",
"state-gallery-page",
"auth-example-page",
"sample-contract-page",
"reference-resource-page",
"reference-resource-detail-page",
"reference-resource-form-page",
"reference-resource-status-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"searchCodec": ["none", "SampleResourceListQuery"]
"paramsCodec": ["none", "NotFoundSplat", "ReferenceResourceParams"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"references": [
{
@@ -82,7 +91,7 @@
},
{
"registryId": "FE-REG-API",
"path": "src/contracts/api-operations.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "API_OPERATIONS",
"owner": "feature-api-client-response-envelope-contract",
"requiredFields": [
@@ -139,7 +148,7 @@
},
{
"registryId": "FE-REG-QUERY",
"path": "src/contracts/query-keys.js",
"path": "src/features/installed-feature-contracts.js",
"exportName": "QUERY_REGISTRY",
"owner": "feature-server-state-caching-contract",
"requiredFields": [
+1 -1
View File
@@ -4,7 +4,7 @@ Automated axe checks do not establish WCAG conformance. A human reviewer must
review all six route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. The required scope is derived from the route
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
`SAMPLE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
gate rejects blank identity/timestamp/signature fields, pending verdicts,
mismatched release IDs, or missing routes.
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -33,14 +33,13 @@
특히 다음은 선행 해결이 필요하다.
RP-01~RP-04에서 TypeScript 도구 안전망, application runtime 주입,
RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약 구현됐다. 현재 선행 해결
recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결
대상은 다음과 같다.
1. 전체를 제거할 수 있는 실제 reference feature
2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
2. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다.
@@ -68,25 +67,25 @@ recovery 계약은 구현됐다. 현재 선행 해결
| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
| 검증 | 준비 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 |
| 서버 상태 | 준비 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 |
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status slot 계약과 reference 소비 화면 | RP-07 public design-system entry로 이동 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 |
| 폼 | 미제공 | 수동 문자열 검증 예제만 존재 | schema 기반 form facade와 422/dirty/pending 정책 |
| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
| 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
| 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 |
| 샘플 제거 | 준비 | feature/catalog/test 제거 후 type/architecture/registry/test/home/build 8단계 검증 | 새 contribution도 같은 제거 gate에 포함 |
| registry·compatibility 집행 | 부분 준비 | registry와 gate는 있으나 실제 before/after 및 orphan 검사가 제한적 | type/reference/orphan/diff/migration을 자동 검증 |
| 공급망 검사 | 부분 준비 | lockfile·문서·gate는 있으나 실제 transitive 취약점/license/SBOM 깊이가 부족 | pinned scanner와 policy exception/증적 연결 |
| realtime·offline·file 등 | 프로젝트 선택 | 현재 없음 | port/adapter recipe와 선택 기준 제공 |
@@ -159,8 +158,8 @@ timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는
#### RP-04에서 route registry를 실행 계약으로 전환
`src/contracts/routes.js` 직렬화 가능한
`src/contracts/route-runtime-contract.js` 기준으로
platform route 계약과 `src/features/installed-feature-contracts.js` 직렬화
가능한 contribution을 기준으로
`src/presentation/routes/app-router.tsx`가 Data Router route object와
navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만
소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry
@@ -179,16 +178,18 @@ gate가 모두 거절한다.
연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를
production application input까지 연결한다.
#### reference feature가 완전히 제거되지 않는다
#### RP-05에서 제거 가능한 reference feature 구현
현재 sample removal gate는 `src/sample/contract-fixture`만 삭제한다. sample API
operation, schema, mapper, domain model, query key는 다른 production 경로에 남는다.
반면 화면에 노출된 `/sample/resources`는 실제 query 수직 흐름을 실행하지 않는다.
`src/features/reference-feature`가 domain, application input, outbound gateway,
DTO/schema, mapper, route/API/query contract, query/mutation controller와 page를
한 소유 경계에 둔다. production composition은 generic feature input catalog를
통해 이 input을 주입하며 UI는 HTTP나 output port를 직접 보지 않는다.
reference feature는 domain, application input/output, schemas, operation,
mapper, query controller, pages, tests를 한 소유 경계 아래 모아야 한다. 해당 모듈과
registry contribution을 제거한 뒤 typecheck, architecture, test, build가 모두
통과해야 “제거 가능”으로 판정한다.
`test:sample-removal`은 임시 복제본에서 feature source/tests를 삭제하고 installed
contract/runtime/adapter catalog를 빈 목록으로 재생성한다. 그 뒤 typecheck,
architecture, registry, unit/integration, home smoke, build와 fixture ID 잔여
0개를 검사한다. 설치 모드에서는 MSW를 사용한 bootstrap → router → application
→ HTTP → schema → mapper → query cache → page 수직 테스트가 실행된다.
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다
@@ -235,8 +236,8 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
### 5.2 P1: 공통 플랫폼 기본 제공 항목
- schema 기반 form facade와 field/error/pending/dirty/422 정책
- standard, collection, detail, form, status page template
- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton`
@@ -508,6 +508,22 @@ RP-06은 기존 reference controls/layout으로 돌아가도 controller/applicat
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다.
**구현 증거 (2026-07-26)**
- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
채택했고 vendor 도입 조건을 문서화했다.
- `src/presentation/forms`가 field registration, parse/error map,
dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
duplicate submit과 dirty navigation을 제공한다.
- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
계약을 제공하며 architecture negative fixture가 application/vendor import를
거절한다.
- reference list/detail/create/status route가 네 구체 template를 사용하고
production composition test가 list → form → command → HTTP → invalidation →
list 경로를 실행한다.
- component/integration/E2E test가 validation, transform, 422, conflict,
secret 비노출, navigation focus와 320px reflow를 검증한다.
### 07. `feature-frontend-design-system-platform`
**목표**
@@ -116,7 +116,7 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다
| `src/adapters` | HTTP, auth, storage, cache, telemetry 구현 | outbound adapter |
| `src/bootstrap` | runtime config와 구현 조립 | 유일한 composition root |
| `src/contracts` | 여러 계층의 registry가 혼재 | 소유 계층으로 분산 |
| `src/sample` | 제거 가능 예제 일부 | 완전한 removable reference feature |
| `src/features/reference-feature` | 완전한 제거 가능 수직 예제 | installed contribution과 8단계 제거 gate 유지 |
현재 구조가 잘 제공하는 기반은 다음과 같다.
@@ -154,15 +154,22 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다.
- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는
비활성화한다.
RP-04에서 첫 번째 실행 불일치는 닫혔다. route registry와 runtime map은
RP-04에서 route 실행 불일치는 닫혔다. route registry와 runtime map은
Data Router tree, codec, surface, title, navigation, chunk/release recovery의
단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다.
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
contract/presentation은 `src/features/reference-feature`가 소유하고, generic
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다.
RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
presentation schema, controlled field state, error focus, 422 allowlist,
pending/deduplication과 dirty navigation을 local facade로 감싼다.
`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
@@ -1222,11 +1229,11 @@ contract와 실패 분기를 우선한다.
### 26.5 Reference feature
- [ ] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
- [ ] list/create 등 최소 query와 mutation 예제가 있다.
- [ ] loading/empty/error/refresh/conflict 상태가 있다.
- [ ] reference feature 전체 삭제 후 typecheck/test/build가 통과한다.
- [ ] built asset에 reference operation, schema, mapper가 남지 않는다.
- [x] route부터 API mapper와 화면까지 완전한 수직 경로가 실행된다.
- [x] list/create 등 최소 query와 mutation 예제가 있다.
- [x] loading/empty/error/refresh/conflict 상태가 있다.
- [x] reference feature 전체 삭제 후 typecheck/architecture/registry/test/home/build가 통과한다.
- [x] 제거 모드 built asset에 reference operation, schema, mapper가 남지 않는다.
### 26.6 품질
@@ -1250,8 +1257,8 @@ contract와 실패 분기를 우선한다.
### P1: 기본 플랫폼 완성도
1. error/result/validation/form kernel
2. 배타적인 async 상태와 page template
1. 완료: error/result/validation/form kernel
2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계
+3 -2
View File
@@ -44,12 +44,13 @@ flowchart LR
The current executable route tree is mounted only after runtime configuration
and release-manifest coherence pass. It receives the composed query client,
credential-opaque session port, storage port, telemetry port, and immutable
build ID. Visible starter pages do not depend on the removable sample fixture.
build ID. Generic starter pages do not depend on the removable reference
feature.
This describes the current starter composition, not the completed target. The
capability review found that raw outbound capabilities still reach the React
tree, the composed application facade is not yet its entry point, and several
route, HTTP, recovery, telemetry, and sample-removal contracts are only
route, HTTP, recovery, telemetry, and reference-feature removal contracts are only
partially connected. Use the following documents for the evidence and migration
plan:
@@ -12,9 +12,9 @@
- page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴
## 2. 현재 상태와 문제
## 2. 현재 상태와 구현 기준
RP-04 이후 현재 구현에는 다음 장점이 있다.
RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다.
- contract에서 Data Router route object와 navigation을 생성한다.
@@ -22,18 +22,26 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
- 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
- `src/presentation/templates`가 standard/collection/detail/form/status slot,
landmark와 responsive layout을 제공한다.
- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
duplicate submit과 dirty route blocker를 소유한다.
- reference feature의 list/detail/create/status route가 네 template variation을
production composition에서 실행한다.
`src/contracts/routes.js`, `src/contracts/route-runtime-contract.js`,
`src/presentation/routes/route-runtime.tsx`의 완전성은 TypeScript와 registry
platform route 계약, `src/features/installed-feature-contracts.js`,
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
negative fixture가 함께 검사한다. params/search codec, loading/error surface,
access, title, navigation, chunk ID는
`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
구현해야 한다.
template는 데이터를 가져오지 않는다. reference page controller가 route input과
application input을 query/form facade에 연결하고, template에는 render할 slot과
안전한 callback만 전달한다. 이 분리는
`page-templates-own-layout-only` dependency rule과 forbidden import fixture가
검증한다.
## 3. React Router mode 결정
+13 -11
View File
@@ -11,7 +11,8 @@ primitives, and state surfaces remain reusable.
validated config + coherent release manifest
-> concrete adapters
-> QueryClientProvider
-> BrowserRouter
-> ApplicationProvider
-> RouterProvider
-> ThemeProvider
-> SessionProvider
-> AppShell
@@ -31,7 +32,7 @@ remain stable in the shell.
| `EXAMPLES_UI` | `/examples/ui` | public | interactive primitives and tokens |
| `EXAMPLES_STATES` | `/examples/states` | public | async and access state matrix |
| `EXAMPLES_AUTH` | `/examples/auth` | public | session integration controls |
| `SAMPLE_RESOURCE_LIST` | `/sample/resources` | integration-defined | protected integration seam |
| `REFERENCE_RESOURCE_LIST` | `/examples/reference-resources` | integration-defined | removable vertical slice |
| `NOT_FOUND` | `*` | public | safe navigation recovery |
Navigation labels and order come from `ROUTE_REGISTRY`; the sidebar does not
@@ -54,24 +55,25 @@ An external owner implements `readState`, `subscribe`, `beginSignIn`,
## Extending the starter
The steps below describe the current extension path. The platform review found
that several route metadata fields and the composed application facade are not
yet connected end to end. New platform work should follow
The steps below describe the current extension path. New platform work should follow
[routing, page templates, and reusable patterns](./routing-pages-and-patterns.md)
and the
[TypeScript, state, and data-flow target](./typescript-state-and-data-flow.md)
rather than adding another independent route or data-loading convention.
1. Register the route path, access hint, title, chunk, loading surface, error
surface, and optional navigation metadata in `src/contracts/routes.js`.
2. Add a lazy page in `src/presentation/` and render it through `RouteSurface`.
3. Use application ports or use cases; do not import concrete adapters.
1. Add a serializable contribution under the feature ownership boundary and
install it through `src/features/installed-feature-contracts.js`.
2. Add the lazy component and route codecs through
`src/features/installed-feature-runtimes.tsx`.
3. Compose feature application inputs and outbound gateways only through
`src/features/installed-feature-adapters.ts`.
4. Use the semantic tokens, UI primitives, and state surfaces before adding a
project-specific variant.
5. Add component behavior, all-engine E2E, automated axe, and signed manual
route evidence.
6. Run `test:sample-removal` to prove the visible starter still builds without
`src/sample/contract-fixture`.
6. Run `test:sample-removal` to prove the generic starter typechecks, passes
architecture/registry/tests/home smoke, and builds without the complete
reference feature.
Theme preference is the public `COLOR_SCHEME` storage contract. Authentication
tokens and other secrets remain forbidden storage keys.
@@ -487,8 +487,12 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
## 8. 폼 표준
form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에
둔다. vendor 선택과 무관하게 다음 API를 제공한다.
VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
Form 또는 TanStack Form adapter를 평가한다.
현재 public API는 다음과 같다.
- `Form`
- `FormField`
@@ -500,6 +504,18 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- `mapValidationFailureToFields`
- `useDirtyNavigationGuard`
구현 위치:
- controller와 mapping: `src/presentation/forms`
- layout-only template: `src/presentation/templates`
- feature form schema/command mapper:
`src/features/reference-feature/presentation/reference-resource-form.ts`
- 실제 create page:
`src/features/reference-feature/presentation/reference-resource-form-page.tsx`
`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path``code`만 담는다.
backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
필수 동작:
1. label, description, error를 stable ID와 `aria-describedby`로 연결
+8 -4
View File
@@ -34,6 +34,10 @@
- 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음
- `src/presentation/forms`
- local form facade, field/error summary, dirty navigation dialog
- `src/presentation/templates`
- Standard, Collection, Detail, Form, Status page template
- `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트
@@ -47,8 +51,8 @@
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
breakpoint가 계약으로 닫혀 있지 않다.
2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수
다.
2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸과 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
class를 다시 작성하는 곳이 있다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
@@ -56,8 +60,8 @@
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지
템플릿이 없다.
8. page template은 준비됐지만 RP-07 design-system public entry와 token/pattern
계층으로 정리해야 한다.
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus
복원, 배경 비활성화 계약은 없다.
+3 -2
View File
@@ -37,8 +37,9 @@ Arbitrary-value policy:
- user-controlled or runtime-composed class strings are forbidden
- class variants must be selected from a closed static map
The removable sample may demonstrate tokens, but production starter modules do
not import from `src/sample/contract-fixture`.
The removable reference feature may demonstrate tokens, but generic production
starter modules do not import its domain, application, adapter, or presentation
implementation.
This file documents the currently implemented token and primitive baseline.
The [design-system platform contract](./design-system-platform.md) defines the
@@ -446,6 +446,24 @@ tests/support/query/
helper는 production default를 복사하지 않는다. production factory를 호출하고
필요한 시간·retry만 test override로 주입한다.
RP-05의 executable reference matrix는
`tests/features/reference-feature`에 모여 있다. 설치 모드에서는 URL codec과
query key/HTTP search의 동일성, DTO 차단, loading/success/empty/terminal,
refreshing/stale, pending/duplicate/optimistic/conflict/rollback과 MSW production
composition을 검증한다. 제거 모드는 feature source/tests와 installed
contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset
잔여 0개를 다시 검증한다.
RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`,
`tests/component/page-templates.test.tsx`,
`tests/features/reference-feature/reference-page.test.tsx`
`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와
첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422
field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation
confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다.
HTTP integration test는 backend copy를 버리고 422 `path`/`code`만 전달하는지
별도로 검사한다.
## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을
+68
View File
@@ -190,12 +190,79 @@ export default [
]),
},
},
{
files: [`src/presentation/templates/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/application/**",
"**/adapters/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
},
},
{
files: [`src/features/*/domain/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.domain),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/application/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports(layerPatterns.application),
"no-restricted-globals": [
"error",
"window",
"document",
"localStorage",
"fetch",
],
},
},
{
files: [`src/features/*/presentation/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/features/*/adapters/**",
"**/adapters/http/**",
"**/adapters/storage/**",
"**/adapters/auth/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
]),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
},
},
{
files: [`src/features/*/adapters/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/presentation/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`tests/**/*.${sourceExtensions}`],
languageOptions: {
@@ -216,6 +283,7 @@ export default [
"@tanstack/**",
"react",
"react-dom",
"**/application/**",
]),
"no-restricted-globals": [
"error",
+3 -1
View File
@@ -26,6 +26,7 @@
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -34,7 +35,8 @@
"test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs",
"review:a11y-manual": "node scripts/verify-a11y-manual.mjs",
"test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration",
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature",
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
"scan:security": "node scripts/security-scan.mjs",
+4 -1
View File
@@ -13,7 +13,10 @@
"route-examples-ui": "src/presentation/examples/ui-gallery-page.jsx",
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.jsx",
"route-sample-resources": "src/presentation/pages/sample-contract-page.jsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
}
+5
View File
@@ -173,6 +173,11 @@ const adHocPatterns = [
/** @param {string} directory */
async function scanDirectory(directory) {
try {
await access(directory);
} catch {
return;
}
const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }),
);
+4 -2
View File
@@ -3,8 +3,10 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { z } from "zod";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.js";
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
+5 -8
View File
@@ -1,11 +1,8 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
);
const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard",
+4 -3
View File
@@ -6,7 +6,7 @@ import process from "node:process";
import { chromium } from "@playwright/test";
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js";
const server = spawn(
"corepack",
@@ -67,8 +67,9 @@ try {
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
const targetLabel =
ROUTE_REGISTRY.SAMPLE_RESOURCE_LIST.navigationLabel;
const targetLabel = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
)?.navigationLabel;
if (!targetLabel) {
throw new Error("Performance route must be present in navigation.");
}
+188 -45
View File
@@ -1,78 +1,221 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const fixtureRoot = path.resolve(".tmp/sample-removal");
const sampleRoot = path.resolve("src/sample/contract-fixture");
const sourceRoot = path.resolve("src");
const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
const featureOwnedPaths = [
featureSource,
featureTests,
"tests/e2e/reference-form.spec.js",
];
const copyTargets = [
"src",
"tests",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",
"eslint.config.js",
".dependency-cruiser.cjs",
];
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
export const INSTALLED_FEATURE_CONTRACTS =
/** @type {readonly unknown[]} */ (Object.freeze([]));
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({});
export const QUERY_REGISTRY = Object.freeze({});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry =
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(\`Unregistered route: \${routeId}\`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
`;
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
`;
const emptyAdapters = `type FeatureContext = Readonly<{
createHttpClient(contract: Readonly<Record<string, unknown>>): unknown;
}>;
export function createInstalledFeatureInputs(_context: FeatureContext) {
void _context;
return Object.freeze({});
}
`;
/** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(directory) {
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
const groups = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target];
return entry.isDirectory() ? filesBelow(target) : [target];
}),
));
return nested.flat();
);
return groups.flat();
}
/** @param {string} script @param {string[]} [extra] */
function runPnpm(script, extra = []) {
const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], {
cwd: fixtureRoot,
stdio: "inherit",
});
return result.status === 0;
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
for (const target of copyTargets) {
await cp(target, path.join(fixtureRoot, target), { recursive: true });
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
const incomingImports = [];
for (const sourceFile of await sourceFiles(sourceRoot)) {
if (sourceFile.startsWith(sampleRoot)) continue;
const content = await readFile(sourceFile, "utf8");
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
incomingImports.push(path.relative(".", sourceFile));
for (const ownedPath of featureOwnedPaths) {
await rm(path.join(fixtureRoot, ownedPath), {
recursive: true,
force: true,
});
}
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-runtimes.tsx"),
emptyRuntimes,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters,
);
/** @type {string[]} */
const residue = [];
for (const root of ["src", "tests"]) {
for (const file of await filesBelow(path.join(fixtureRoot, root))) {
const relative = path.relative(fixtureRoot, file);
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(
`${relative}\n${content}`,
)
) {
residue.push(relative);
}
}
}
let buildStatus = 1;
if (incomingImports.length === 0) {
await cp("src", path.join(fixtureRoot, "src"), {
recursive: true,
filter: (source) => !source.startsWith(sampleRoot),
});
await cp("public", path.join(fixtureRoot, "public"), { recursive: true });
await cp("index.html", path.join(fixtureRoot, "index.html"));
await cp("vite.config.js", path.join(fixtureRoot, "vite.config.js"));
const result = spawnSync(
process.execPath,
[
pnpmCli,
"exec",
"vite",
"build",
fixtureRoot,
"--outDir",
path.join(fixtureRoot, "dist"),
],
{ stdio: "inherit" },
);
buildStatus = result.status ?? 1;
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["registry", runPnpm("check:registries")],
["unit-integration", runPnpm("test:all")],
[
"home-smoke",
runPnpm("exec", [
"vitest",
"run",
"tests/component/router.test.jsx",
"--reporter=default",
]),
],
["build", runPnpm("build")],
];
/** @type {string[]} */
const builtResidue = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
builtResidue.push(path.relative(fixtureRoot, file));
}
}
const routeCatalog = await import(
`${new URL(
"../src/features/installed-feature-contracts.js",
`file://${fixtureRoot}/scripts/`,
).href}?removed=${Date.now()}`
);
const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY);
const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_"));
checks.push(["route-absent", routeAbsent]);
checks.push(["fixture-id-residue", residue.length === 0]);
checks.push(["built-fixture-id-residue", builtResidue.length === 0]);
const passed = checks.every(([, result]) => result);
await mkdir("artifacts/tests", { recursive: true });
const passed = incomingImports.length === 0 && buildStatus === 0;
await writeFile(
"artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
`<testcase name="no-product-import"/>` +
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
`<testsuite name="reference-feature-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
checks
.map(
([name, result]) =>
`<testcase name="${name}">${result ? "" : `<failure>${[...residue, ...builtResidue].join(", ")}</failure>`}</testcase>`,
)
.join("") +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
const failures = checks
.filter(([, result]) => !result)
.map(([name]) => name);
process.stderr.write(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`,
`Reference feature removal failed: ${failures.join(", ")}; residue: ${[...residue, ...builtResidue].join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Sample removal smoke: PASS\n");
process.stdout.write(
`Reference feature removal: PASS (${checks.length} checks, no fixture IDs)\n`,
);
+4 -2
View File
@@ -6,8 +6,10 @@ import {
compareReleaseToRuntime,
RELEASE_TOKEN_REGISTRY,
} from "../src/contracts/release-tokens.js";
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
import { ROUTE_REGISTRY } from "../src/contracts/routes.js";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.js";
const fixturesDocument =
/** @type {{
+14 -1
View File
@@ -4,6 +4,7 @@ import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
@@ -46,6 +47,8 @@ const noAuthSession =
* random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* validateRequest?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
@@ -61,6 +64,8 @@ export function createHttpClient(dependencies) {
const random = dependencies.random ?? Math.random;
const validatePayload =
dependencies.validatePayload ?? validateOperationPayload;
const validateRequest =
dependencies.validateRequest ?? validateOperationRequest;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
@@ -193,7 +198,7 @@ export function createHttpClient(dependencies) {
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateOperationRequest(
const requestValidation = validateRequest(
operation.requestSchema,
requestValue,
);
@@ -411,6 +416,10 @@ async function parseResponse(
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
@@ -422,6 +431,10 @@ async function parseResponse(
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
+1 -26
View File
@@ -1,30 +1,5 @@
import { createResource } from "../../domain/models/resource.js";
/** @param {unknown} value */
export function mapResourceDto(value) {
if (!value || typeof value !== "object") {
throw new TypeError("Validated resource DTO is required");
}
const dto = /** @type {Record<string, unknown>} */ (value);
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated resource DTO invariants were breached");
}
return createResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
if (operationId === "LIST_SAMPLE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a resource list");
return payload.map(mapResourceDto);
}
if (operationId === "CREATE_SAMPLE_RESOURCE") {
return mapResourceDto(payload);
}
void payload;
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
+2 -26
View File
@@ -37,35 +37,11 @@ export const responseEnvelopeSchema = z.discriminatedUnion("success", [
failureEnvelopeSchema,
]);
const sampleResourceSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().optional(),
})
.passthrough();
const payloadSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListPayload: z.array(sampleResourceSchema),
SampleResourcePayload: sampleResourceSchema,
}));
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
const requestSchemas =
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({
SampleResourceListQuery: z
.object({
cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20),
tags: z.array(z.string().trim().min(1)).optional(),
})
.strict(),
CreateSampleResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
})
.strict(),
}));
/** @type {Readonly<Record<string, z.ZodType>>} */ (Object.freeze({}));
/** @param {unknown} value */
export function validateEnvelope(value) {
+14
View File
@@ -15,6 +15,7 @@ export type { ApplicationApi, ApplicationOutputPorts };
*/
export function createApplication(
outputPorts: ApplicationOutputPorts,
featureInputs: Readonly<Record<string, unknown>> = {},
): ApplicationApi {
const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(),
@@ -107,6 +108,18 @@ export function createApplication(
}
},
});
const installedFeatureInputs = Object.freeze({ ...featureInputs });
const features = Object.freeze({
has(featureId: string) {
return Object.hasOwn(installedFeatureInputs, featureId);
},
get(featureId: string) {
if (!Object.hasOwn(installedFeatureInputs, featureId)) {
throw new Error(`Application feature is not installed: ${featureId}`);
}
return installedFeatureInputs[featureId];
},
});
return Object.freeze({
session,
@@ -114,5 +127,6 @@ export function createApplication(
diagnostics,
runtime,
recovery,
features,
});
}
@@ -47,4 +47,8 @@ export type ApplicationApi = Readonly<{
| Readonly<{ action: "support"; reason: string }>
>;
}>;
features: Readonly<{
has(featureId: string): boolean;
get(featureId: string): unknown;
}>;
}>;
-6
View File
@@ -7,11 +7,5 @@ export type {
export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type {
RequestContext,
ResourceCommandPort,
ResourceQueryPort,
Result,
} from "../resource-ports.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
-28
View File
@@ -1,28 +0,0 @@
/**
* @template Query
* @template Model
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
*/
/**
* @template Command
* @template Model
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
*/
/**
* @typedef {{
* operationId: string,
* routeId: string,
* signal?: AbortSignal,
* idempotencyKey?: string
* }} RequestContext
*/
/**
* @template Value
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
*/
export {};
@@ -1,16 +0,0 @@
/**
* @param {import("../../domain/models/resource.js").Resource} resource
* @param {(value: Date) => string} [formatDate]
*/
export function toResourceViewModel(
resource,
formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value),
) {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
+6 -2
View File
@@ -16,7 +16,8 @@ import { createApplication } from "../application/create-application.js";
* release: Release
* }): Promise<{
* outputPorts: OutputPorts,
* infrastructure: Infrastructure
* infrastructure: Infrastructure,
* featureInputs?: Readonly<Record<string, unknown>>
* }>
* }} factories
* @returns {Promise<Readonly<{
@@ -30,7 +31,10 @@ export async function createCompositionRoot(factories) {
const config = await factories.loadConfig();
const release = await factories.loadRelease(config);
const adapters = await factories.createAdapters({ config, release });
const application = createApplication(adapters.outputPorts);
const application = createApplication(
adapters.outputPorts,
adapters.featureInputs,
);
return Object.freeze({
config,
+15 -1
View File
@@ -10,6 +10,7 @@ import {
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
import { fetchReleaseManifest } from "./load-release-manifest.js";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.js";
/**
* @param {Record<string, unknown>} host
@@ -54,7 +55,7 @@ function storageOrUndefined(value) {
* scheduler?: Parameters<typeof createHttpClient>[0]["scheduler"]
* }} context
*/
export function createRuntimeHttpClient(context) {
export function createRuntimeHttpClient(context, contract = {}) {
return createHttpClient({
baseUrl: context.runtime.config.API_BASE_URL,
timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS,
@@ -63,6 +64,7 @@ export function createRuntimeHttpClient(context) {
fetcher: context.fetcher,
clock: context.clock,
scheduler: context.scheduler,
...contract,
});
}
@@ -116,6 +118,17 @@ export async function createRuntimeAdapters(context) {
location.reload();
},
});
const featureInputs = createInstalledFeatureInputs({
createHttpClient: (contract) =>
createRuntimeHttpClient(
{
runtime: context.runtime,
authSession,
fetcher: context.fetcher,
},
contract,
),
});
return Object.freeze({
outputPorts: Object.freeze({
@@ -128,5 +141,6 @@ export async function createRuntimeAdapters(context) {
infrastructure: Object.freeze({
queryClient,
}),
featureInputs,
});
}
+3 -33
View File
@@ -14,41 +14,11 @@
* }} ApiOperation
*/
/** @param {ApiOperation} definition */
const operation = (definition) => Object.freeze(definition);
export const API_OPERATIONS = Object.freeze({
LIST_SAMPLE_RESOURCES: operation({
method: "GET",
path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
CREATE_SAMPLE_RESOURCE: operation({
method: "POST",
path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture",
}),
});
export const API_OPERATIONS = Object.freeze({});
/** @param {string} operationId */
export function getApiOperation(operationId) {
const registry = /** @type {Record<string, ApiOperation>} */ (API_OPERATIONS);
export function getApiOperation(operationId, operations = API_OPERATIONS) {
const registry = /** @type {Record<string, ApiOperation>} */ (operations);
const selected = registry[operationId];
if (!selected) {
throw new Error(`Unregistered API operation: ${operationId}`);
+58
View File
@@ -195,6 +195,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* userMessageKey: string,
* action: ErrorAction,
* causeClass?: string
@@ -211,6 +212,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* causeClass?: string
* }} [details]
* @returns {ApiFailure}
@@ -236,6 +238,24 @@ export function createFailure(kind, operationId, attempt, details = {}) {
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
...(Array.isArray(details.validationIssues)
? {
validationIssues: Object.freeze(
details.validationIssues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string",
)
.slice(0, 50)
.map((issue) =>
Object.freeze({ path: issue.path, code: issue.code }),
),
),
}
: {}),
...(typeof details.causeClass === "string"
? { causeClass: details.causeClass }
: {}),
@@ -244,6 +264,44 @@ export function createFailure(kind, operationId, attempt, details = {}) {
});
}
/**
* Projects an untrusted 422 details payload into the only validation metadata
* allowed to cross the HTTP boundary. Backend copy and additional values are
* deliberately discarded.
*
* @param {unknown} value
* @returns {readonly Readonly<{path: string, code: string}>[]}
*/
export function safeValidationIssues(value) {
if (!value || typeof value !== "object") return Object.freeze([]);
const candidate =
/** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value);
const issues = Array.isArray(candidate.issues)
? candidate.issues
: Array.isArray(candidate.fieldErrors)
? candidate.fieldErrors
: [];
return Object.freeze(
issues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string" &&
issue.path.length <= 120 &&
issue.code.length <= 80,
)
.slice(0, 50)
.map((issue) =>
Object.freeze({
path: issue.path,
code: issue.code,
}),
),
);
}
/** @param {number} status */
export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED";
+1 -23
View File
@@ -1,26 +1,4 @@
const RESOURCE_NAMESPACE = Object.freeze(["resource", 1]);
export const queryKeys = Object.freeze({
resource: Object.freeze({
all: () => RESOURCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...RESOURCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...RESOURCE_NAMESPACE, "detail", String(resourceId)]),
}),
});
export const QUERY_REGISTRY = Object.freeze({
RESOURCE: Object.freeze({
namespace: RESOURCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
});
export const QUERY_REGISTRY = Object.freeze({});
/** @param {unknown} value @returns {unknown} */
export function canonicalize(value) {
+2 -8
View File
@@ -1,11 +1,11 @@
/**
* @typedef {"none" | "NotFoundSplat" | "SampleResourceListQuery"} RouteCodecId
* @typedef {"none" | "NotFoundSplat"} RouteCodecId
*/
/** @param {Readonly<{routeId: string, moduleId: string, paramsCodec: RouteCodecId, searchCodec: RouteCodecId}>} value */
const runtime = (value) => Object.freeze(value);
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
APP_HOME: runtime({
routeId: "APP_HOME",
moduleId: "home-page",
@@ -30,12 +30,6 @@ export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
paramsCodec: "none",
searchCodec: "none",
}),
SAMPLE_RESOURCE_LIST: runtime({
routeId: "SAMPLE_RESOURCE_LIST",
moduleId: "sample-contract-page",
paramsCodec: "none",
searchCodec: "SampleResourceListQuery",
}),
NOT_FOUND: runtime({
routeId: "NOT_FOUND",
moduleId: "not-found-page",
+1 -39
View File
@@ -17,7 +17,7 @@
/** @param {RouteDefinition} definition */
const route = (definition) => Object.freeze(definition);
export const ROUTE_REGISTRY = Object.freeze({
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({
routeId: "APP_HOME",
path: "/",
@@ -70,19 +70,6 @@ export const ROUTE_REGISTRY = Object.freeze({
navigationLabel: "인증 연동",
navigationOrder: 40,
}),
SAMPLE_RESOURCE_LIST: route({
routeId: "SAMPLE_RESOURCE_LIST",
path: "/sample/resources",
paramsSchema: null,
searchSchema: "SampleResourceListQuery",
access: "integration-defined",
loadingSurface: "sample-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-sample-resources",
title: "보호된 연동 지점",
navigationLabel: "보호된 연동 지점",
navigationOrder: 50,
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
path: "*",
@@ -97,28 +84,3 @@ export const ROUTE_REGISTRY = Object.freeze({
navigationOrder: null,
}),
});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
-19
View File
@@ -1,19 +0,0 @@
/**
* @typedef {{
* id: string,
* displayName: string,
* createdAt: string | null
* }} Resource
*/
/** @param {Resource} values @returns {Readonly<Resource>} */
export function createResource(values) {
if (!values.id || !values.displayName) {
throw new TypeError("Resource invariants require id and displayName");
}
return Object.freeze({
id: values.id,
displayName: values.displayName,
createdAt: values.createdAt,
});
}
@@ -0,0 +1,15 @@
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.js";
export function createInstalledFeatureInputs(
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
): Readonly<Record<string, unknown>> {
const installed = [createReferenceFeatureInstalledInput(context)];
return Object.freeze(
Object.fromEntries(
installed.map((contribution) => [
contribution.featureId,
contribution.input,
]),
),
);
}
@@ -0,0 +1,48 @@
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
import { REFERENCE_FEATURE_CONTRACT } from "./reference-feature/contracts/reference-feature-contract.js";
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
]);
export const ROUTE_REGISTRY = Object.freeze({
...PLATFORM_ROUTE_REGISTRY,
...REFERENCE_FEATURE_CONTRACT.routes,
});
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
});
export const API_OPERATIONS = Object.freeze({
...REFERENCE_FEATURE_CONTRACT.apiOperations,
});
export const QUERY_REGISTRY = Object.freeze({
...REFERENCE_FEATURE_CONTRACT.queryRegistry,
});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
),
);
/** @param {string} routeId */
export function getRoute(routeId) {
const registry =
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
ROUTE_REGISTRY
);
const selected = registry[routeId];
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
return selected;
}
/** @param {string} routeId */
export function routePath(routeId) {
return getRoute(routeId).path;
}
@@ -0,0 +1,16 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
import {
REFERENCE_FEATURE_ROUTE_CODECS,
REFERENCE_FEATURE_ROUTE_RUNTIME,
} from "./reference-feature/presentation/reference-feature-runtime.js";
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...REFERENCE_FEATURE_ROUTE_CODECS,
});
export const ROUTE_RUNTIME = Object.freeze({
...PLATFORM_ROUTE_RUNTIME,
...REFERENCE_FEATURE_ROUTE_RUNTIME,
});
+39
View File
@@ -0,0 +1,39 @@
# Reference feature ownership and removal
이 모듈은 제품 도메인이 아니라 새 기능의 수직 경계를 검증하는 제거 가능한
reference implementation이다.
## 소유 경계
- `domain`: 외부 DTO와 React를 모르는 불변 model
- `application`: UI가 호출하는 list/get/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/form controller로 연결하는 inbound
adapter, 독립 form schema/command mapper와 list/detail/form/status page
generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이
자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output
port나 TanStack API를 직접 import하지 않는다.
## 설치 지점
- 직렬화 계약: `src/features/installed-feature-contracts.js`
- component/codec: `src/features/installed-feature-runtimes.tsx`
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application,
router나 HTTP client에 하드코딩하지 않는다.
## 검증과 제거
```sh
corepack pnpm test:reference-feature
corepack pnpm test:sample-removal
```
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 source/built fixture ID 잔여 0개를 검사한다.
@@ -0,0 +1,43 @@
import { createReferenceFeatureInput } from "../application/reference-feature-api.js";
import {
REFERENCE_FEATURE_CONTRACT,
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.js";
import { mapReferenceOperation } from "../contracts/reference-mapper.js";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../contracts/reference-schemas.js";
import { createReferenceHttpGateway } from "./reference-http-gateway.js";
type HttpContract = Readonly<{
getOperation(operationId: string): unknown;
validatePayload: typeof validateReferencePayload;
validateRequest: typeof validateReferenceRequest;
mapPayload: typeof mapReferenceOperation;
}>;
type HttpExecutor = Parameters<typeof createReferenceHttpGateway>[0];
export function createReferenceFeatureInstalledInput(context: Readonly<{
createHttpClient(contract: HttpContract): HttpExecutor;
}>) {
const operations =
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, unknown>>;
const http = context.createHttpClient({
getOperation(operationId) {
const operation = operations[operationId];
if (!operation) {
throw new Error(`Unknown reference operation: ${operationId}`);
}
return operation;
},
validatePayload: validateReferencePayload,
validateRequest: validateReferenceRequest,
mapPayload: mapReferenceOperation,
});
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(http)),
});
}
@@ -0,0 +1,71 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import type {
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
type HttpResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; error: ApiFailure }>;
type HttpExecutor = Readonly<{
execute(
request: Readonly<{
operationId: string;
routeId: string;
pathParams?: Record<string, string | number>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
}>,
): Promise<HttpResult>;
}>;
export function createReferenceHttpGateway(
http: HttpExecutor,
): ReferenceGateway {
return Object.freeze({
async list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
searchParams: filters,
signal: context?.signal,
});
return result.ok
? {
ok: true as const,
value: result.value as readonly ReferenceResource[],
}
: result;
},
async create(command: Readonly<{ name: string; note?: string }>) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
async get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId },
signal: context?.signal,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
});
}
@@ -0,0 +1,72 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import {
toReferenceView,
type ReferenceResourceView,
} from "../contracts/reference-mapper.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
export type ReferenceListFilters = Readonly<{
cursor?: string;
limit: number;
tags?: readonly string[];
}>;
export type ReferenceResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type ReferenceFeatureInput = Readonly<{
listResources(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: Readonly<{ name: string; note?: string }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
export type ReferenceGateway = Readonly<{
list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string; note?: string }>,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResource>>;
}>;
export function createReferenceFeatureInput(
gateway: ReferenceGateway,
): ReferenceFeatureInput {
return Object.freeze({
async listResources(filters, context) {
const result = await gateway.list(filters, context);
return result.ok
? {
ok: true as const,
value: result.value.map((resource) => toReferenceView(resource)),
}
: result;
},
async createResource(command) {
const result = await gateway.create(command);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
async getResource(resourceId, context) {
const result = await gateway.get(resourceId, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
});
}
@@ -0,0 +1,148 @@
import { canonicalize } from "../../../contracts/query-keys.js";
export const REFERENCE_FEATURE_ID = "reference-feature";
const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1]);
export const referenceQueryKeys = Object.freeze({
all: () => REFERENCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]),
});
export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
routes: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
path: "/examples/reference-resources",
paramsSchema: null,
searchSchema: "ReferenceResourceListQuery",
access: "integration-defined",
loadingSurface: "reference-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-reference-resources",
title: "Reference feature",
navigationLabel: "Reference feature",
navigationOrder: 50,
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
path: "/examples/reference-resources/:resourceId",
paramsSchema: "ReferenceResourceParams",
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-detail",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-detail",
title: "Reference detail",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
path: "/examples/reference-resources/new",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-form",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-form",
title: "Reference form",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
path: "/examples/reference-resources/status",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-status",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-status",
title: "Reference status",
navigationLabel: null,
navigationOrder: null,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
moduleId: "reference-resource-page",
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
moduleId: "reference-resource-detail-page",
paramsCodec: "ReferenceResourceParams",
searchCodec: "none",
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
moduleId: "reference-resource-form-page",
paramsCodec: "none",
searchCodec: "none",
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
moduleId: "reference-resource-status-page",
paramsCodec: "none",
searchCodec: "none",
}),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: Object.freeze({
method: "GET",
path: "/api/reference-resources",
operationId: "LIST_REFERENCE_RESOURCES",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "ReferenceResourceListQuery",
responseSchema: "ReferenceResourceListPayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
CREATE_REFERENCE_RESOURCE: Object.freeze({
method: "POST",
path: "/api/reference-resources",
operationId: "CREATE_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateReferenceResourceCommand",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
GET_REFERENCE_RESOURCE: Object.freeze({
method: "GET",
path: "/api/reference-resources/{resourceId}",
operationId: "GET_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "none",
requestSchema: "NoRequest",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-form-page-platform",
}),
}),
queryRegistry: Object.freeze({
REFERENCE_RESOURCE: Object.freeze({
namespace: REFERENCE_NAMESPACE,
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "reference resource namespace after successful mutation",
version: 1,
persistence: "disabled",
}),
}),
});
@@ -0,0 +1,57 @@
import {
createReferenceResource,
type ReferenceResource,
} from "../domain/reference-resource.js";
export type ReferenceResourceView = Readonly<{
resourceId: string;
title: string;
createdAtLabel: string | null;
optimistic?: boolean;
}>;
function mapReferenceDto(value: unknown): ReferenceResource {
if (!value || typeof value !== "object") {
throw new TypeError("Validated reference DTO is required");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated reference DTO invariants were breached");
}
return createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): ReferenceResource | readonly ReferenceResource[] {
if (operationId === "LIST_REFERENCE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a reference list");
return payload.map(mapReferenceDto);
}
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
}
export function toReferenceView(
resource: ReferenceResource,
formatDate: (value: Date) => string = (value) =>
new Intl.DateTimeFormat("ko-KR").format(value),
): ReferenceResourceView {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
@@ -0,0 +1,74 @@
import { z } from "zod";
export const referenceResourceListQuerySchema = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z
.preprocess(
(value) =>
value === undefined
? undefined
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
)
.optional(),
})
.strict();
export const referenceResourceParamsSchema = z
.object({
resourceId: z.string().trim().min(1).max(120),
})
.strict();
const referenceResourceDtoSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
createdAt: z.string().datetime().optional(),
})
.strict();
const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema),
ReferenceResourcePayload: referenceResourceDtoSchema,
} satisfies Record<string, z.ZodType>;
const requestSchemas = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
} satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) {
return result.success
? ({ success: true as const, data: structuredClone(result.data) })
: ({
success: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
});
}
export function validateReferencePayload(schemaId: string, value: unknown) {
const schema = payloadSchemas[schemaId as keyof typeof payloadSchemas];
return schema
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
export function validateReferenceRequest(schemaId: string, value: unknown) {
const schema = requestSchemas[schemaId as keyof typeof requestSchemas];
return schema
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
@@ -0,0 +1,14 @@
export type ReferenceResource = Readonly<{
id: string;
displayName: string;
createdAt: string | null;
}>;
export function createReferenceResource(
values: ReferenceResource,
): ReferenceResource {
if (!values.id || !values.displayName) {
throw new TypeError("Reference resource invariants require id and displayName");
}
return Object.freeze({ ...values });
}
@@ -0,0 +1,30 @@
import { lazy } from "react";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
Component: lazy(() => import("./reference-resource-detail-page.js")),
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
moduleId: "reference-resource-form-page",
Component: lazy(() => import("./reference-resource-form-page.js")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.js")),
}),
} as const;
@@ -0,0 +1,47 @@
import { Link } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { DetailPage } from "../../../presentation/templates/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useReferenceDetail } from "./use-reference-feature.js";
export default function ReferenceResourceDetailPage() {
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
return (
<DetailPage
key={resourceId}
breadcrumb={
<Link to="/examples/reference-resources">Reference resources</Link>
}
heading={{
eyebrow: "DetailPage",
title: resource?.title ?? "Reference detail",
description: "route param과 detail query의 reset 경계를 확인합니다.",
}}
metadata={
resource ? (
<dl>
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>{resource.createdAtLabel ?? "표시 정보 없음"}</dd>
</dl>
) : (
<p> .</p>
)
}
feedback={
<AsyncSurface state={query.state} onRetry={query.retry}>
{resource ? (
<p> section을 .</p>
) : null}
</AsyncSurface>
}
aside={<p> slot입니다.</p>}
/>
);
}
@@ -0,0 +1,131 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../../presentation/components/ui/button.jsx";
import {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/forms/index.js";
import { FormPage } from "../../../presentation/templates/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.js";
import { useReferenceCreate } from "./use-reference-feature.js";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
note: "설명",
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
export default function ReferenceResourceFormPage() {
const navigate = useNavigate();
const mutation = useReferenceCreate();
const submit = useCallback(
(command: ReturnType<typeof toCreateReferenceCommand>) =>
mutation.submit(command),
[mutation],
);
const form = useAppForm({
schema: referenceResourceFormSchema,
defaultValues: REFERENCE_FORM_DEFAULTS,
allowedServerFields: ["name", "note"],
mapToCommand: toCreateReferenceCommand,
submit,
});
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => void form.submitForm(event)}
>
<FormPage
breadcrumb={
<button
className="ui-button ui-button--ghost"
type="button"
onClick={() => navigate("/examples/reference-resources")}
>
</button>
}
heading={{
eyebrow: "FormPage",
title: "Reference resource 만들기",
description:
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
}}
errorSummary={
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={FIELD_LABELS}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
}
fields={
<>
<FormField
{...form.field("name")}
label={FIELD_LABELS.name}
description="앞뒤 공백은 command mapper 전에 제거됩니다."
autoComplete="off"
required
/>
<FormField
{...form.field("note")}
label={FIELD_LABELS.note}
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
autoComplete="off"
/>
</>
}
formActions={
<FormActions sticky>
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending}
>
</Button>
<Button type="submit" disabled={form.pending}>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending}
>
</Button>
</FormActions>
}
feedback={
form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
) : null
}
aside={
<p>
form value는 URL, storage, telemetry에 submit
application command로 .
</p>
}
guard={<DirtyNavigationDialog guard={guard} />}
/>
</Form>
);
}
@@ -0,0 +1,29 @@
import { z } from "zod";
export const referenceResourceFormSchema = z
.object({
name: z
.string()
.trim()
.min(2, "이름은 두 글자 이상이어야 합니다.")
.max(120),
note: z.string().trim().max(500).default(""),
})
.strict();
export type ReferenceResourceFormValues = z.infer<
typeof referenceResourceFormSchema
>;
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
Object.freeze({
name: "",
note: "",
});
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
return Object.freeze({
name: values.name,
...(values.note ? { note: values.note } : {}),
});
}
@@ -0,0 +1,53 @@
import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { CollectionPage } from "../../../presentation/templates/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const navigate = useNavigate();
const { filters, query } = useReferenceFeature();
return (
<CollectionPage
heading={{
eyebrow: "제거 가능한 수직 슬라이스",
title: "Reference feature",
description:
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
}}
actions={[
{
kind: "button",
label: "새 항목 만들기",
onAction: () => navigate("/examples/reference-resources/new"),
},
]}
activeFilters={
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
}
toolbar={<Button onClick={() => void query.retry()}></Button>}
resultCount={
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li key={resource.resourceId}>
<Link
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
>
{resource.title}
</Link>
</li>
))}
</ul>
</AsyncSurface>
</CollectionPage>
);
}
@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/templates/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
return (
<StatusPage
variant="maintenance"
heading={{
eyebrow: "StatusPage · maintenance",
title: "잠시 사용할 수 없습니다.",
description:
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
}}
primaryAction={{
kind: "button",
label: "목록으로 이동",
onAction: () => navigate("/examples/reference-resources"),
}}
supportReference="REFERENCE-STATUS-DEMO"
/>
);
}
@@ -0,0 +1,81 @@
import { useApplication } from "../../../presentation/providers/application-provider.js";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import type { ReferenceResourceView } from "../contracts/reference-mapper.js";
import {
REFERENCE_FEATURE_ID,
referenceQueryKeys,
} from "../contracts/reference-feature-contract.js";
import type {
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
export function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
typeof (candidate as ReferenceFeatureInput).createResource !== "function" ||
typeof (candidate as ReferenceFeatureInput).getResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const query = useApplicationQuery({
queryKey: referenceQueryKeys.detail(resourceId),
execute: ({ signal }) => input.getResource(resourceId, { signal }),
});
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
return useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
});
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const routeInput = useRouteInput();
const filters = routeInput.search as ReferenceListFilters;
const queryKey = referenceQueryKeys.list(filters);
const query = useApplicationQuery({
queryKey,
execute: ({ signal }) => input.listResources(filters, { signal }),
});
const mutation = useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
optimistic: {
queryKey,
update(previous, command: Readonly<{ name: string }>) {
const current = Array.isArray(previous)
? (previous as readonly ReferenceResourceView[])
: [];
return [
...current,
{
resourceId: `optimistic:${command.name}`,
title: command.name,
createdAtLabel: null,
optimistic: true,
},
];
},
},
});
return Object.freeze({ filters, query, mutation });
}
@@ -0,0 +1,90 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import type {
FieldErrors,
FieldName,
FormValues,
} from "./form-contracts.js";
export function Form(
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
) {
const { pending = false, children, ...formProps } = props;
return (
<form {...formProps} noValidate aria-busy={pending || undefined}>
{children}
</form>
);
}
export function FormField(
props: React.ComponentProps<typeof TextField>,
) {
return <TextField {...props} />;
}
export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors?: readonly string[];
fieldLabels: Readonly<Record<FieldName<Values>, string>>;
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const {
fieldErrors,
formErrors = [],
fieldLabels,
fieldId,
onFocusField,
} = props;
const headingId = useId();
const entries = Object.entries(fieldErrors) as [
FieldName<Values>,
string,
][];
if (entries.length === 0 && formErrors.length === 0) return null;
return (
<section
className="form-error-summary"
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}> .</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
<li key={name}>
<a
href={`#${fieldId(name)}`}
onClick={(event) => {
if (!onFocusField) return;
event.preventDefault();
onFocusField(name);
}}
>
{fieldLabels[name]}: {message}
</a>
</li>
))}
</ul>
) : null}
{formErrors.map((message) => (
<p key={message}>{message}</p>
))}
</section>
);
}
export function FormActions(props: Readonly<{
children: ReactNode;
sticky?: boolean;
}>) {
return (
<div
className={`form-actions${props.sticky ? " form-actions--sticky" : ""}`}
>
{props.children}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { ApiFailure } from "../../contracts/errors.js";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
export type FieldErrors<Values extends FormValues> = Readonly<
Partial<Record<FieldName<Values>, string>>
>;
export type FormResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type FormResultState =
| "idle"
| "success"
| "validation-error"
| "conflict"
| "unavailable";
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors: readonly string[];
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "필수 입력값입니다.",
too_small: "입력값이 너무 짧습니다.",
too_big: "입력값이 너무 깁니다.",
invalid_type: "입력 형식을 확인해 주세요.",
invalid_format: "입력 형식을 확인해 주세요.",
invalid_value: "허용된 값을 선택해 주세요.",
});
export function validationMessage(code: string): string {
return (
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"입력값을 확인해 주세요."
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
allowedFields: readonly FieldName<Values>[],
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
}
const allowed = new Set<string>(allowedFields);
const fieldErrors: Partial<Record<FieldName<Values>, string>> = {};
const formErrors: string[] = [];
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push("입력값을 다시 확인해 주세요.");
}
for (const issue of issues) {
const field = issue.path.split(".").at(0) ?? "";
if (allowed.has(field)) {
const name = field as FieldName<Values>;
fieldErrors[name] ??= validationMessage(issue.code);
} else {
formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
}
}
return Object.freeze({
fieldErrors: Object.freeze(fieldErrors),
formErrors: Object.freeze([...new Set(formErrors)]),
});
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./form-components.js";
export * from "./form-contracts.js";
export * from "./use-app-form.js";
export * from "./use-dirty-navigation-guard.js";
+244
View File
@@ -0,0 +1,244 @@
import {
useCallback,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
mapValidationFailureToFields,
validationMessage,
type FieldErrors,
type FieldName,
type FormResult,
type FormResultState,
type FormValues,
} from "./form-contracts.js";
type AppFormOptions<
Values extends FormValues,
Command,
Output,
> = Readonly<{
schema: ZodType<Values>;
defaultValues: Values;
allowedServerFields: readonly FieldName<Values>[];
mapToCommand(values: Values): Command;
submit(command: Command): Promise<FormResult<Output>>;
resetOnSuccess?: boolean;
}>;
export function useAppForm<
Values extends FormValues,
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const {
schema,
defaultValues,
allowedServerFields,
mapToCommand,
submit,
resetOnSuccess = true,
} = options;
const generatedId = useId().replaceAll(":", "");
const formId = `app-form-${generatedId}`;
const [values, setValues] = useState<Values>(defaultValues);
const [initialValues, setInitialValues] = useState<Values>(defaultValues);
const [touched, setTouched] = useState<ReadonlySet<FieldName<Values>>>(
() => new Set(),
);
const [fieldErrors, setFieldErrors] = useState<FieldErrors<Values>>(
() => ({} as FieldErrors<Values>),
);
const [formErrors, setFormErrors] = useState<readonly string[]>([]);
const [pending, setPending] = useState(false);
const [result, setResult] = useState<FormResultState>("idle");
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
const dirty = useMemo(
() => JSON.stringify(values) !== JSON.stringify(initialValues),
[initialValues, values],
);
const fieldId = useCallback(
(name: FieldName<Values>) => `${formId}-${name}`,
[formId],
);
const focusField = useCallback(
(name: FieldName<Values>) => {
const field = document.getElementById(fieldId(name));
if (field instanceof HTMLElement) field.focus();
},
[fieldId],
);
const focusFirstError = useCallback(
(errors: FieldErrors<Values>) => {
const first = allowedServerFields.find((name) => Boolean(errors[name]));
if (first) focusField(first);
},
[allowedServerFields, focusField],
);
const setValue = useCallback(
(name: FieldName<Values>, value: Values[FieldName<Values>]) => {
setValues((current) => ({ ...current, [name]: value }) as Values);
setFieldErrors((current) => {
if (!current[name]) return current;
const next = { ...current };
delete next[name];
return next;
});
setFormErrors([]);
setResult("idle");
},
[],
);
const field = useCallback(
(name: FieldName<Values>) => ({
id: fieldId(name),
name,
value: String(values[name] ?? ""),
onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setValue(name, event.currentTarget.value as Values[FieldName<Values>]);
},
onBlur() {
setTouched((current) => new Set(current).add(name));
},
error: fieldErrors[name],
"aria-invalid": fieldErrors[name] ? ("true" as const) : undefined,
}),
[fieldErrors, fieldId, setValue, values],
);
const reset = useCallback(
(nextValues: Values = defaultValues) => {
setValues(nextValues);
setInitialValues(nextValues);
setTouched(new Set());
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("idle");
},
[defaultValues],
);
const submitForm = useCallback(
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
event?.preventDefault();
if (pendingRef.current) return pendingRef.current;
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
const parsed = await schema.safeParseAsync(values);
if (!parsed.success) {
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? ["입력 구성을 다시 확인해 주세요."]
: [],
);
setTouched(new Set(allowedServerFields));
setResult("validation-error");
focusFirstError(errors);
return null;
}
const command = mapToCommand(parsed.data);
setPending(true);
const execution = submit(command);
pendingRef.current = execution;
try {
const outcome = await execution;
if (outcome.ok) {
setResult("success");
if (resetOnSuccess) {
setValues(defaultValues);
setInitialValues(defaultValues);
setTouched(new Set());
} else {
setInitialValues(parsed.data);
}
return outcome;
}
if (outcome.error.kind === "VALIDATION_REJECTED") {
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
setResult("validation-error");
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
]);
setResult("conflict");
} else {
setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
setResult("unavailable");
}
return outcome;
} finally {
pendingRef.current = null;
setPending(false);
}
},
[
allowedServerFields,
defaultValues,
focusFirstError,
mapToCommand,
resetOnSuccess,
schema,
submit,
values,
],
);
return Object.freeze({
formId,
values,
dirty,
touched,
fieldErrors,
formErrors,
pending,
result,
field,
fieldId,
focusField,
setValue,
submitForm,
reset,
});
}
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
for (const issue of issues) {
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code);
}
return Object.freeze(errors);
}
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
useBeforeUnload(
useCallback(
(event) => {
if (!when) return;
event.preventDefault();
event.returnValue = "";
},
[when],
),
{ capture: true },
);
return Object.freeze({
blocked: blocker.state === "blocked",
stay() {
blocker.reset?.();
},
leave() {
blocker.proceed?.();
},
});
}
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title="저장하지 않은 변경이 있습니다."
description="이 화면을 떠나면 입력한 내용이 사라집니다."
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
</Button>
<Button variant="danger" onClick={props.guard.leave}>
</Button>
</>
}
/>
);
}
+4 -1
View File
@@ -1,7 +1,10 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
export default function NotFoundPage() {
@@ -1,23 +0,0 @@
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function SampleContractPage() {
const { sessionState } = useSession();
return (
<section className="ui-page">
<PageHeader
eyebrow="보호 라우트"
title="보호된 연동 지점"
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
/>
<section className="ui-panel" aria-labelledby="protected-state-title">
<h2 id="protected-state-title">라우트 접근 허용</h2>
<p>
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
권한 검증은 클라이언트 라우트 정책과 별도로 유지해야 합니다.
</p>
</section>
</section>
);
}
+3 -3
View File
@@ -21,8 +21,8 @@ import {
import {
getRoute,
ROUTE_REGISTRY,
type RouteDefinition,
} from "../../contracts/routes.js";
} from "../../features/installed-feature-contracts.js";
import type { RouteDefinition } from "../../contracts/routes.js";
import {
FeatureBoundary,
RouteBoundary,
@@ -43,7 +43,7 @@ import {
type ParsedRouteInput,
type RouteId,
} from "./route-codecs.js";
import { ROUTE_RUNTIME } from "./route-runtime.js";
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.js";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
+1 -1
View File
@@ -1,4 +1,4 @@
import { getRoute } from "../../contracts/routes.js";
import { getRoute } from "../../features/installed-feature-contracts.js";
/**
* @param {string} routeId
@@ -0,0 +1,6 @@
import { z } from "zod";
export const PLATFORM_ROUTE_CODECS = {
none: z.object({}).strict(),
NotFoundSplat: z.object({ "*": z.string().optional() }).strict(),
} as const;
+17 -35
View File
@@ -1,36 +1,8 @@
import { z } from "zod";
import { getRoute } from "../../contracts/routes.js";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
const emptyCodec = z.object({}).strict();
const notFoundSplatCodec = z.object({ "*": z.string().optional() }).strict();
const sampleResourceListQuery = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z
.preprocess(
(value) =>
value === undefined
? undefined
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
)
.optional(),
})
.strict();
const codecs = {
none: emptyCodec,
NotFoundSplat: notFoundSplatCodec,
SampleResourceListQuery: sampleResourceListQuery,
} as const;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
@@ -44,17 +16,23 @@ export type RouteInputResult =
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
function codecById(codecId: string) {
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`);
return codec;
}
export function parseRouteInput(
routeId: RouteId,
rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams,
): RouteInputResult {
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].safeParse(rawParams);
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
if (!params.success) {
return { success: false, code: "ROUTE_PARAMS_INVALID" };
}
const search = codecs[runtime.searchCodec].safeParse(
const search = codecById(runtime.searchCodec).safeParse(
searchRecord(rawSearch),
);
if (!search.success) {
@@ -84,14 +62,18 @@ export function buildRouteUrl(
throw new TypeError("The not-found route cannot build a canonical URL");
}
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].parse(input.params ?? {});
const search = codecs[runtime.searchCodec].parse(input.search ?? {});
const params = codecById(runtime.paramsCodec).parse(input.params ?? {});
const search = codecById(runtime.searchCodec).parse(input.search ?? {});
const parsedParams: Record<string, unknown> = { ...params };
const parsedSearch: Record<string, unknown> = { ...search };
let path = definition.path;
path = path.replace(
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
(_token, colonName: string | undefined, braceName: string | undefined) => {
(
_token: string,
colonName: string | undefined,
braceName: string | undefined,
) => {
const name = colonName ?? braceName ?? "";
const value = parsedParams[name];
if (typeof value !== "string" && typeof value !== "number") {
+5 -10
View File
@@ -4,8 +4,7 @@ import {
type LazyExoticComponent,
} from "react";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import type { RouteId } from "./route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
@@ -14,16 +13,16 @@ type RouteRuntime = Readonly<{
}>;
function runtime(
routeId: RouteId,
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
load: () => Promise<RouteModule>,
): RouteRuntime {
return Object.freeze({
moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
export const ROUTE_RUNTIME = {
export const PLATFORM_ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
@@ -37,12 +36,8 @@ export const ROUTE_RUNTIME = {
"EXAMPLES_AUTH",
() => import("../examples/auth-example-page.jsx"),
),
SAMPLE_RESOURCE_LIST: runtime(
"SAMPLE_RESOURCE_LIST",
() => import("../pages/sample-contract-page.jsx"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.jsx"),
),
} satisfies Record<RouteId, RouteRuntime>;
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
+153
View File
@@ -590,6 +590,141 @@
line-height: 1.7;
}
.page-template {
display: grid;
gap: 1.25rem;
min-width: 0;
}
.page-template__breadcrumb {
padding-block-start: 1rem;
color: var(--color-content-muted);
font-size: 0.9rem;
}
.page-template__heading {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 1rem;
}
.page-template__heading .page-header {
grid-row: span 2;
min-width: 0;
}
.page-template__status,
.page-template__actions,
.status-page__actions,
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.page-template__layout {
display: grid;
min-width: 0;
gap: 1.5rem;
}
.page-template__layout[data-has-aside="true"] {
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
}
.page-template__content,
.page-template__aside,
.collection-page__results,
.detail-page__sections,
.form-page__fields {
min-width: 0;
}
.page-template__aside,
.detail-page__metadata,
.collection-page__toolbar,
.form-error-summary {
border: 1px solid var(--color-border);
border-radius: var(--radius-surface);
padding: 1rem;
background: var(--color-panel);
}
.collection-page__toolbar,
.collection-page__active-filters,
.collection-page__bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
}
.collection-page__result-count {
color: var(--color-content-muted);
}
.collection-page__results,
.detail-page__sections,
.form-page__fields,
.form-page__error-summary {
display: grid;
gap: 1rem;
}
.collection-page__pagination {
margin-block-start: 1.25rem;
}
.detail-page__danger {
margin-block-start: 2rem;
border-top: 1px solid var(--color-danger-border);
padding-block-start: 1rem;
}
.form-error-summary {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.form-error-summary h2 {
font-size: 1.1rem;
}
.form-error-summary p,
.form-error-summary ul {
margin-block-end: 0;
}
.form-actions {
padding-block-start: 1rem;
border-top: 1px solid var(--color-border);
}
.form-actions--sticky {
position: sticky;
z-index: 10;
bottom: 0;
padding: 0.75rem;
background: color-mix(in oklch, var(--color-panel) 94%, transparent);
}
.status-page {
max-width: 48rem;
padding-block: 2rem;
}
.status-page__actions {
justify-content: flex-start;
}
.status-page__support {
color: var(--color-content-muted);
}
.readiness-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -832,6 +967,24 @@
.component-grid--three {
grid-template-columns: minmax(0, 1fr);
}
.page-template__heading,
.page-template__layout[data-has-aside="true"] {
grid-template-columns: minmax(0, 1fr);
}
.page-template__heading .page-header {
grid-row: auto;
}
.page-template__actions,
.page-template__status {
justify-content: flex-start;
}
.form-actions--sticky {
margin-inline: calc(var(--spacing-page) * -1);
}
}
@media (prefers-reduced-motion: reduce) {
+1
View File
@@ -0,0 +1 @@
export * from "./page-templates.js";
@@ -0,0 +1,246 @@
import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Button } from "../components/ui/button.jsx";
export type PageHeading = Readonly<{
title: string;
description?: string;
eyebrow?: string;
}>;
export type PageActionDefinition =
| Readonly<{
kind: "button";
label: string;
onAction(): void;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>
| Readonly<{
kind: "link";
label: string;
href: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>;
export type PageTemplateSlots = Readonly<{
heading: PageHeading;
breadcrumb?: ReactNode;
status?: ReactNode;
actions?: readonly PageActionDefinition[];
notices?: ReactNode;
children?: ReactNode;
aside?: ReactNode;
feedback?: ReactNode;
}>;
export function StandardPage(props: PageTemplateSlots) {
return (
<article className="ui-page page-template page-template--standard">
{props.breadcrumb ? (
<nav className="page-template__breadcrumb" aria-label="현재 위치">
{props.breadcrumb}
</nav>
) : null}
<div className="page-template__heading">
<PageHeader {...props.heading} />
{props.status ? (
<div className="page-template__status">{props.status}</div>
) : null}
{props.actions?.length ? (
<PageActionBar actions={props.actions} />
) : null}
</div>
{props.notices ? (
<div className="page-template__notices">{props.notices}</div>
) : null}
{props.feedback ? (
<div className="page-template__feedback">{props.feedback}</div>
) : null}
<div
className="page-template__layout"
data-has-aside={props.aside ? "true" : "false"}
>
<div className="page-template__content">{props.children}</div>
{props.aside ? (
<aside className="page-template__aside" aria-label="관련 정보">
{props.aside}
</aside>
) : null}
</div>
</article>
);
}
export function CollectionPage(
props: PageTemplateSlots &
Readonly<{
toolbar?: ReactNode;
activeFilters?: ReactNode;
resultCount?: ReactNode;
bulkActions?: ReactNode;
pagination?: ReactNode;
}>,
) {
return (
<StandardPage
{...props}
notices={
<>
{props.notices}
{props.toolbar ? (
<section
className="collection-page__toolbar"
aria-label="검색과 필터"
>
{props.toolbar}
</section>
) : null}
{props.activeFilters ? (
<div className="collection-page__active-filters">
{props.activeFilters}
</div>
) : null}
{props.resultCount ? (
<div className="collection-page__result-count" role="status">
{props.resultCount}
</div>
) : null}
{props.bulkActions ? (
<div className="collection-page__bulk-actions">
{props.bulkActions}
</div>
) : null}
</>
}
>
<div className="collection-page__results">{props.children}</div>
{props.pagination ? (
<nav className="collection-page__pagination" aria-label="페이지 탐색">
{props.pagination}
</nav>
) : null}
</StandardPage>
);
}
export function DetailPage(
props: PageTemplateSlots &
Readonly<{
metadata?: ReactNode;
destructiveAction?: ReactNode;
}>,
) {
return (
<StandardPage {...props}>
{props.metadata ? (
<section className="detail-page__metadata" aria-label="요약 정보">
{props.metadata}
</section>
) : null}
<div className="detail-page__sections">{props.children}</div>
{props.destructiveAction ? (
<section className="detail-page__danger" aria-label="위험 작업">
{props.destructiveAction}
</section>
) : null}
</StandardPage>
);
}
export function FormPage(
props: PageTemplateSlots &
Readonly<{
errorSummary?: ReactNode;
fields?: ReactNode;
formActions?: ReactNode;
guard?: ReactNode;
}>,
) {
return (
<StandardPage {...props}>
{props.errorSummary ? (
<div className="form-page__error-summary">{props.errorSummary}</div>
) : null}
<div className="form-page__fields">{props.fields ?? props.children}</div>
{props.formActions ? (
<div className="form-page__actions">{props.formActions}</div>
) : null}
{props.guard}
</StandardPage>
);
}
export type StatusPageVariant =
| "unauthenticated"
| "forbidden"
| "not-found"
| "unavailable"
| "offline"
| "maintenance"
| "unexpected";
export function StatusPage(
props: Readonly<{
variant: StatusPageVariant;
heading: PageHeading;
primaryAction?: PageActionDefinition;
secondaryAction?: PageActionDefinition;
supportReference?: string;
}>,
) {
return (
<section
className={`ui-page page-template status-page status-page--${props.variant}`}
data-status-variant={props.variant}
>
<PageHeader {...props.heading} />
{props.primaryAction || props.secondaryAction ? (
<PageActionBar
className="status-page__actions"
actions={
[props.primaryAction, props.secondaryAction].filter(
Boolean,
) as PageActionDefinition[]
}
/>
) : null}
{props.supportReference ? (
<p className="status-page__support">
: <code>{props.supportReference}</code>
</p>
) : null}
</section>
);
}
function PageActionBar(props: Readonly<{
actions: readonly PageActionDefinition[];
className?: string;
}>) {
return (
<div className={props.className ?? "page-template__actions"}>
{props.actions.map((action) =>
action.kind === "link" ? (
<a
className={`ui-button ui-button--${action.variant ?? "primary"}`}
href={action.href}
key={`${action.kind}:${action.label}`}
>
{action.label}
</a>
) : (
<Button
disabled={action.disabled}
key={`${action.kind}:${action.label}`}
onClick={action.onAction}
variant={action.variant}
>
{action.label}
</Button>
),
)}
</div>
);
}
@@ -1,15 +0,0 @@
export function DesignTokenShowcase() {
return (
<section className="ui-panel" aria-labelledby="token-showcase-title">
<h2 id="token-showcase-title" className="text-xl font-semibold">
Design token fixture
</h2>
<p className="text-content-muted">
Semantic tokens style loading, empty, and terminal surfaces.
</p>
<button className="ui-button" type="button">
Token action
</button>
</section>
);
}
@@ -1,53 +0,0 @@
import { toResourceViewModel } from "../../application/view-models/resource-view-model.js";
import { queryKeys } from "../../contracts/query-keys.js";
/**
* @param {{
* http: { execute(operationId: string, input?: Record<string, unknown>): Promise<
* {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure}
* > },
* cache: import("../../application/ports/query-cache-port.js").QueryCachePort
* }} ports
*/
export function createSampleFacade(ports) {
return Object.freeze({
async listResources(filters = {}) {
const key = queryKeys.resource.list(filters);
const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", {
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const models =
/** @type {Array<import("../../domain/models/resource.js").Resource>} */ (
result.value
);
const cached = ports.cache.write(key, models);
if (!cached.ok) return cached;
return {
ok: /** @type {true} */ (true),
value: models.map((model) => toResourceViewModel(model)),
};
},
/** @param {{ name: string }} command */
async createResource(command) {
const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", {
body: command,
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const invalidated = await ports.cache.invalidate(queryKeys.resource.all());
if (!invalidated.ok) return invalidated;
return {
ok: /** @type {true} */ (true),
value: toResourceViewModel(
/** @type {import("../../domain/models/resource.js").Resource} */ (
result.value
),
),
};
},
});
}
@@ -1,61 +0,0 @@
import { useEffect, useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { AsyncSurface } from "../../presentation/components/async-surface.jsx";
/**
* @typedef {{
* loading: boolean,
* resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>,
* failure?: import("../../contracts/errors.js").ApiFailure
* }} SamplePageState
*/
/**
* @param {{
* facade: ReturnType<typeof import("./sample-facade.js").createSampleFacade>
* }} props
*/
export function SampleResourcePage({ facade }) {
const [result, setResult] = useState(
/** @type {SamplePageState} */ ({
loading: true,
resources: undefined,
failure: undefined,
}),
);
useEffect(() => {
let active = true;
void facade.listResources().then((outcome) => {
if (!active) return;
setResult(
outcome.ok
? { loading: false, resources: outcome.value, failure: undefined }
: { loading: false, resources: undefined, failure: outcome.error },
);
});
return () => {
active = false;
};
}, [facade]);
const state = deriveAsyncState({
isInitialLoading: result.loading,
data: result.resources,
failure: result.failure,
});
return (
<main>
<h1>샘플 리소스</h1>
<AsyncSurface state={state}>
<ul>
{(result.resources ?? []).map((resource) => (
<li key={resource.resourceId}>{resource.title}</li>
))}
</ul>
</AsyncSurface>
</main>
);
}
+8 -3
View File
@@ -3,12 +3,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DesignTokenShowcase } from "../../src/sample/contract-fixture/design-token-showcase.jsx";
import { Button } from "../../src/presentation/components/ui/button.jsx";
import { Card } from "../../src/presentation/components/ui/card.jsx";
describe("design-token fixture", () => {
it("uses static semantic primitive classes", () => {
render(<DesignTokenShowcase />);
expect(screen.getByRole("region")).toHaveClass("ui-panel");
render(
<Card title="Design token fixture">
<Button>Token action</Button>
</Card>,
);
expect(screen.getByRole("article")).toHaveClass("ui-card");
expect(screen.getByRole("button")).toHaveClass("ui-button");
});
});
+199
View File
@@ -0,0 +1,199 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
import { z } from "zod";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.js";
import { Button } from "../../src/presentation/components/ui/button.jsx";
import {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../src/presentation/forms/index.js";
type Values = Readonly<Record<"name" | "note", string>>;
const schema = z
.object({
name: z.string().trim().min(2),
note: z.string().trim().default(""),
})
.strict();
const defaults: Values = { name: "", note: "" };
function FormHarness(props: Readonly<{
submit(command: Readonly<{ name: string; note?: string }>): Promise<
| Readonly<{ ok: true; value: string }>
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
>;
}>) {
const form = useAppForm({
schema,
defaultValues: defaults,
allowedServerFields: ["name", "note"],
mapToCommand(values) {
return {
name: values.name,
...(values.note ? { note: values.note } : {}),
};
},
submit: props.submit,
});
return (
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={{ name: "Name", note: "Note" }}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
<FormField {...form.field("name")} label="Name" required />
<FormField {...form.field("note")} label="Note" />
<Button type="submit" disabled={form.pending}>
{form.pending ? "Pending" : "Submit"}
</Button>
<Button onClick={() => form.reset()} disabled={!form.dirty}>
Reset
</Button>
<output data-testid="dirty">{String(form.dirty)}</output>
<output data-testid="result">{form.result}</output>
</Form>
);
}
describe("local form facade", () => {
it("focuses the first invalid field and performs no command", async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<FormHarness submit={submit} />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent("Name");
});
it("submits transformed data once and clears dirty state after success", async () => {
const user = userEvent.setup();
let finish: ((value: { ok: true; value: string }) => void) | undefined;
const submit = vi.fn(
() =>
new Promise<{ ok: true; value: string }>((resolve) => {
finish = resolve;
}),
);
render(<FormHarness submit={submit} />);
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
finish?.({ ok: true, value: "saved" });
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
expect(screen.getByTestId("result")).toHaveTextContent("success");
});
it("maps only approved 422 fields and never renders backend copy", async () => {
const user = userEvent.setup();
const failure = createFailure(
"VALIDATION_REJECTED",
"CREATE_ENTITY",
0,
{
validationIssues: [
{ path: "name", code: "REQUIRED" },
{ path: "serverOnly", code: "raw-secret-message" },
],
},
);
render(
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
);
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
expect(screen.getByRole("alert")).toHaveTextContent(
"서버가 확인하지 못한 입력 항목",
);
expect(document.body).not.toHaveTextContent("raw-secret-message");
});
it("keeps conflict input out of URL and storage", async () => {
const user = userEvent.setup();
localStorage.clear();
window.history.replaceState({}, "", "/form-test");
render(
<FormHarness
submit={async () => ({
ok: false,
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
})}
/>,
);
const secretLike = "token-like-do-not-copy";
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
expect(window.location.href).not.toContain(secretLike);
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
});
});
describe("dirty navigation guard", () => {
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
const user = userEvent.setup();
function GuardedPage() {
const navigate = useNavigate();
const [dirty, setDirty] = useState(false);
const guard = useDirtyNavigationGuard(dirty);
return (
<>
<label htmlFor="guard-field">Guard field</label>
<input
id="guard-field"
onChange={() => setDirty(true)}
/>
<Button onClick={() => navigate("/target")}>Leave</Button>
<DirtyNavigationDialog guard={guard} />
</>
);
}
const router = createMemoryRouter(
[
{ path: "/", element: <GuardedPage /> },
{ path: "/target", element: <h1>Target</h1> },
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
const leave = screen.getByRole("button", { name: "Leave" });
await user.click(leave);
expect(
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toHaveAttribute("open");
await user.click(screen.getByRole("button", { name: "계속 작성" }));
await waitFor(() => expect(leave).toHaveFocus());
await user.click(leave);
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
});
});
+92
View File
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CollectionPage,
DetailPage,
FormPage,
StandardPage,
StatusPage,
} from "../../src/presentation/templates/index.js";
describe("page template slot contracts", () => {
it("renders StandardPage minimum and full landmarks with one h1", () => {
const { rerender } = render(
<StandardPage heading={{ title: "Minimum" }}>Content</StandardPage>,
);
expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible();
rerender(
<StandardPage
heading={{ title: "Full", description: "Long heading contract" }}
breadcrumb={<a href="/">Home</a>}
status={<span>Ready</span>}
actions={[
{ kind: "button", label: "Action", onAction: () => {} },
]}
notices={<p>Notice</p>}
feedback={<p role="status">Refreshing</p>}
aside={<p>Aside</p>}
>
Content
</StandardPage>,
);
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible();
expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible();
});
it("places collection, detail and form state in stable slots", () => {
const { rerender } = render(
<CollectionPage
heading={{ title: "Collection" }}
toolbar={<button type="button">Filter</button>}
resultCount="12 results"
pagination={<a href="?page=2">Next</a>}
>
Results
</CollectionPage>,
);
expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible();
expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible();
rerender(
<DetailPage
heading={{ title: "Detail" }}
metadata={<dl><dt>ID</dt><dd>1</dd></dl>}
destructiveAction={<button type="button">Delete</button>}
>
Sections
</DetailPage>,
);
expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible();
expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible();
rerender(
<FormPage
heading={{ title: "Form" }}
errorSummary={<p role="alert">Invalid</p>}
fields={<input aria-label="Field" />}
formActions={<button type="button">Save</button>}
/>,
);
expect(screen.getByRole("alert")).toBeVisible();
expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
});
it("renders safe status variants without raw failure values", () => {
render(
<StatusPage
variant="offline"
heading={{ title: "Offline", description: "Safe recovery copy" }}
primaryAction={{ kind: "button", label: "Retry", onAction: () => {} }}
supportReference="SAFE-123"
/>,
);
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
expect(screen.getByText("SAFE-123")).toBeVisible();
expect(document.body).not.toHaveTextContent("stack");
});
});
+11 -74
View File
@@ -2,34 +2,29 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
createAnonymousSessionAdapter,
createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
import { createTestApplication } from "../helpers/create-test-application.js";
/**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
* @param {Parameters<typeof createTestApplication>[0]} [overrides]
*/
function renderRouter(session, overrides = {}) {
function renderRouter() {
return render(
<ApplicationProvider
application={createTestApplication({ ...overrides, session })}
application={createTestApplication({
session: createAnonymousSessionAdapter(),
})}
>
<AppRouter />
</ApplicationProvider>,
);
}
describe("application router", () => {
it("renders the app shell and not-found route without an API request", async () => {
describe("generic application router", () => {
it("renders the app shell and not-found route without a feature input", async () => {
window.history.pushState({}, "", "/missing");
renderRouter(createAnonymousSessionAdapter());
renderRouter();
expect(
await screen.findByRole("heading", {
@@ -40,10 +35,10 @@ describe("application router", () => {
expect(screen.getByRole("main")).toBeVisible();
});
it("navigates between registry-backed example routes", async () => {
it("navigates between registry-backed platform routes", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
renderRouter(createAnonymousSessionAdapter());
renderRouter();
await user.click(
await screen.findByRole("link", { name: "UI 구성요소" }),
@@ -58,62 +53,4 @@ describe("application router", () => {
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
).toHaveFocus();
});
it("reacts to demo sign-in and opens the protected integration route", async () => {
const user = userEvent.setup();
const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources");
renderRouter(authSession);
expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
await user.click(screen.getByRole("button", { name: "로그인 시작" }));
expect(
await screen.findByRole("heading", { name: "보호된 연동 지점" }),
).toBeVisible();
expect(screen.getByText("인증됨")).toBeVisible();
});
it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources");
renderRouter(createAnonymousSessionAdapter());
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(
screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
});
it("rejects invalid route search before any application query runs", async () => {
const getCurrent = vi.fn(async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: { "route-sample-resources": "assets/sample.js" },
}));
window.history.pushState({}, "", "/sample/resources?limit=invalid");
renderRouter(createDemoSessionAdapter("authenticated"), {
releaseInfo: { getCurrent, refresh: getCurrent },
});
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByText("안전한 탐색 링크를 사용해 주세요.")).toHaveAttribute(
"data-route-error",
"ROUTE_SEARCH_INVALID",
);
expect(getCurrent).not.toHaveBeenCalled();
});
});
@@ -35,7 +35,6 @@ const releaseManifest = {
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-sample-resources": "assets/sample.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -1,70 +0,0 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx";
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: /** @type {const} */ (true),
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
}),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
expect(await screen.findByText("Example")).toBeVisible();
});
it("renders normalized terminal errors without raw DTO fields", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: /** @type {const} */ (false),
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
retryable: true,
operationId: "LIST_SAMPLE_RESOURCES",
attemptCount: 1,
userMessageKey: "error.server_failure",
action: "retry",
},
}),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("요청을 완료하지 못했습니다.");
expect(alert).toHaveAttribute(
"data-message-key",
"error.server_failure",
);
});
});
+5 -8
View File
@@ -1,14 +1,11 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
for (const route of [
"/",
"/examples/ui",
"/examples/states",
"/examples/auth",
"/sample/resources",
"/not-found",
]) {
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
if (definition.path === "*") return "/not-found";
return definition.path.replace(":resourceId", "reference-1");
})) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
+7 -2
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
test("boots the public app shell", async ({ page }) => {
await page.goto("/");
@@ -27,7 +28,11 @@ test("navigates to a registry-backed example without a page reload", async ({
test("opens the protected integration route through the local demo seam", async ({
page,
}) => {
await page.goto("/sample/resources");
const protectedRoute = Object.values(ROUTE_REGISTRY).find(
(definition) => definition.access === "integration-defined",
);
if (!protectedRoute) throw new Error("An integration route is required");
await page.goto(protectedRoute.path);
await expect(
page.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
@@ -35,7 +40,7 @@ test("opens the protected integration route through the local demo seam", async
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "보호된 연동 지점" }),
page.getByRole("heading", { name: protectedRoute.title }),
).toBeVisible();
await expect(page.getByText("인증됨")).toBeVisible();
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
/** @param {import("@playwright/test").Page} page */
async function openReferenceForm(page) {
await page.goto("/examples/reference-resources/new");
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "Reference resource 만들기" }),
).toBeVisible();
}
test("validates a reference form and focuses the first invalid field", async ({
page,
}) => {
await openReferenceForm(page);
await page.getByRole("button", { name: "저장" }).click();
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
await expect(firstField).toBeFocused();
await expect(firstField).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
});
test("guards dirty cancellation and restores focus when writing continues", async ({
page,
}) => {
await openReferenceForm(page);
await page
.getByRole("textbox", { name: /새 항목 이름/ })
.fill("Unsaved reference");
const cancel = page.getByRole("button", { name: "취소" });
await cancel.click();
await expect(
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toBeVisible();
await page.getByRole("button", { name: "계속 작성" }).click();
await expect(cancel).toBeFocused();
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
});
test("keeps the form template within a 320px viewport", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 720 });
await openReferenceForm(page);
const viewport = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildRouteUrl,
parseRouteInput,
} from "../../../src/presentation/routes/route-codecs.js";
import {
mapReferenceOperation,
toReferenceView,
} from "../../../src/features/reference-feature/contracts/reference-mapper.js";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../../../src/features/reference-feature/contracts/reference-schemas.js";
import {
REFERENCE_FEATURE_CONTRACT,
referenceQueryKeys,
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
describe("reference feature boundary contracts", () => {
it("round-trips one canonical filter through URL and query identity", () => {
const filters = {
tags: ["open", "new"],
cursor: "a/b",
limit: 5,
};
const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
expect(url).toBe(
"/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
const parsed = parseRouteInput(
"REFERENCE_RESOURCE_LIST",
{},
new URL(url, "https://app.test").searchParams,
);
expect(parsed).toMatchObject({
success: true,
data: { search: filters },
});
expect(referenceQueryKeys.list(filters).at(-1)).toEqual(filters);
});
it("rejects unknown search and malformed DTO before mapping", () => {
expect(
parseRouteInput(
"REFERENCE_RESOURCE_LIST",
{},
new URLSearchParams("unknown=value"),
),
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
expect(
validateReferencePayload("ReferenceResourceListPayload", [
{ id: "unsafe", name: 42 },
]),
).toMatchObject({ success: false });
expect(() =>
mapReferenceOperation("LIST_REFERENCE_RESOURCES", [
{ id: "unsafe", name: 42 },
]),
).toThrow();
});
it("normalizes request input and maps only owned domain fields", () => {
expect(
validateReferenceRequest("CreateReferenceResourceCommand", {
name: " Example ",
}),
).toMatchObject({ success: true, data: { name: "Example" } });
const model = mapReferenceOperation("CREATE_REFERENCE_RESOURCE", {
id: "reference-1",
name: "Example",
createdAt: "2026-07-26T00:00:00.000Z",
});
if (!("id" in model)) throw new Error("expected one model");
expect(toReferenceView(model, () => "formatted")).toEqual({
resourceId: "reference-1",
title: "Example",
createdAtLabel: "formatted",
});
});
it("owns route, operation and query contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
"REFERENCE_RESOURCE_FORM",
"REFERENCE_RESOURCE_STATUS",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
"GET_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
]);
});
});
@@ -0,0 +1,229 @@
// @vitest-environment jsdom
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.js";
import type {
ReferenceFeatureInput,
ReferenceResult,
} from "../../../src/features/reference-feature/application/reference-feature-api.js";
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.js";
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.js";
import { createFailure } from "../../../src/contracts/errors.js";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../../src/presentation/routes/app-router.js";
import { createTestApplication } from "../../helpers/create-test-application.js";
function renderReference(
input: ReferenceFeatureInput,
url = "/examples/reference-resources?limit=5",
) {
window.history.pushState({}, "", url);
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<ApplicationProvider
application={createTestApplication({
session: createDemoSessionAdapter("authenticated"),
featureInputs: { [REFERENCE_FEATURE_ID]: input },
})}
>
<AppRouter />
</ApplicationProvider>
</QueryClientProvider>,
);
}
function inputWith(
overrides: Partial<ReferenceFeatureInput> = {},
): ReferenceFeatureInput {
return {
listResources: async () => ({ ok: true, value: [] }),
createResource: async ({ name }) => ({
ok: true,
value: {
resourceId: "created",
title: name,
createdAtLabel: null,
},
}),
getResource: async (resourceId) => ({
ok: true,
value: {
resourceId,
title: "Detail",
createdAtLabel: null,
},
}),
...overrides,
};
}
describe("reference feature page states", () => {
it("renders loading, success and empty states through the installed route", async () => {
let resolveList:
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
| undefined;
const pending = new Promise<
ReferenceResult<readonly ReferenceResourceView[]>
>((resolve) => {
resolveList = resolve;
});
const loaded = renderReference(
inputWith({ listResources: async () => pending }),
);
expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
resolveList?.({
ok: true,
value: [
{
resourceId: "reference-1",
title: "Loaded",
createdAtLabel: null,
},
],
});
expect(await screen.findByText("Loaded")).toBeVisible();
loaded.unmount();
renderReference(inputWith(), "/examples/reference-resources?limit=10");
expect(
await screen.findByRole("heading", {
name: "표시할 항목이 없습니다.",
}),
).toBeVisible();
});
it("rejects invalid URL input before the feature application input", async () => {
const listResources = vi.fn();
renderReference(
inputWith({ listResources }),
"/examples/reference-resources?limit=invalid",
);
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(listResources).not.toHaveBeenCalled();
});
it("renders backend forbidden even when the client access hint allowed entry", async () => {
renderReference(
inputWith({
listResources: async () => ({
ok: false,
error: createFailure(
"FORBIDDEN",
"LIST_REFERENCE_RESOURCES",
0,
),
}),
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"이 작업을 수행할 권한이 없습니다.",
);
});
it("deduplicates create, preserves input and surfaces a conflict", async () => {
const user = userEvent.setup();
let finish:
| ((result: ReferenceResult<ReferenceResourceView>) => void)
| undefined;
const createResource = vi.fn(
() =>
new Promise<ReferenceResult<ReferenceResourceView>>((resolve) => {
finish = resolve;
}),
);
renderReference(
inputWith({ createResource }),
"/examples/reference-resources/new",
);
await screen.findByRole("heading", {
name: "Reference resource 만들기",
});
await user.type(
screen.getByRole("textbox", { name: /새 항목 이름/ }),
"Conflicting",
);
await user.type(screen.getByLabelText("설명"), "Keep this input");
const submit = screen.getByRole("button", { name: "저장" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
finish?.({
ok: false,
error: createFailure(
"CONFLICT",
"CREATE_REFERENCE_RESOURCE",
0,
),
});
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(
screen.getByRole("textbox", { name: /새 항목 이름/ }),
).toHaveValue("Conflicting");
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
const user = userEvent.setup();
const listResources = vi
.fn<ReferenceFeatureInput["listResources"]>()
.mockResolvedValueOnce({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
})
.mockResolvedValueOnce({
ok: false,
error: createFailure(
"SERVER_FAILURE",
"LIST_REFERENCE_RESOURCES",
0,
),
})
.mockResolvedValue({
ok: true,
value: [
{
resourceId: "recovered",
title: "Recovered",
createdAtLabel: null,
},
],
});
renderReference(inputWith({ listResources }));
await screen.findByText("Existing");
await user.click(screen.getByRole("button", { name: "새로고침" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByText("Recovered")).toBeVisible();
await waitFor(() =>
expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
);
expect(listResources).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.js";
import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.jsx";
const runtimeConfig = {
APP_ENV: "local",
API_BASE_URL: "https://api.test",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 0,
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
const releaseManifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "local-build",
commitSha: "local",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
routeChunks: {
"route-home": "assets/home.js",
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-reference-resources": "assets/reference.js",
"route-reference-resource-detail": "assets/reference-detail.js",
"route-reference-resource-form": "assets/reference-form.js",
"route-reference-resource-status": "assets/reference-status.js",
"route-not-found": "assets/not-found.js",
},
};
const listRequests = vi.fn();
const createRequests = vi.fn();
const resources = [{ id: "reference-1", name: "Existing" }];
const server = setupServer(
http.get("http://app.test/config.json", () =>
HttpResponse.json(runtimeConfig),
),
http.get("http://app.test/release-manifest.json", () =>
HttpResponse.json(releaseManifest),
),
http.get("https://api.test/api/reference-resources", ({ request }) => {
listRequests(new URL(request.url).search);
return HttpResponse.json({
success: true,
data: resources,
meta: { requestId: "request-list", traceId: "trace-list" },
});
}),
http.post("https://api.test/api/reference-resources", async ({ request }) => {
const body = (await request.json()) as { name: string };
createRequests(body);
const created = { id: "reference-created", name: body.name };
resources.push(created);
return HttpResponse.json({
success: true,
data: created,
meta: { requestId: "request-create", traceId: "trace-create" },
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
listRequests.mockClear();
createRequests.mockClear();
resources.splice(1);
});
afterAll(() => server.close());
const absoluteFetch: typeof fetch = (input, init) => {
if (input instanceof Request) return fetch(input, init);
const url = new URL(
input instanceof URL ? input.href : input,
"http://app.test",
);
return fetch(url, init);
};
describe("reference feature production vertical path", () => {
it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
const user = userEvent.setup();
const composition = await createRuntimeComposition({
fetcher: absoluteFetch,
host: {},
});
window.history.pushState(
{},
"",
"/examples/reference-resources?tags=open&tags=new&limit=5",
);
render(<RuntimeApplication composition={composition} />);
await user.click(
await screen.findByRole("button", { name: "로그인 시작" }),
);
expect(await screen.findByText("Existing")).toBeVisible();
expect(listRequests).toHaveBeenCalledWith(
"?limit=5&tags=open&tags=new",
);
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
await user.type(
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
" Created ",
);
await user.click(screen.getByRole("button", { name: "저장" }));
expect(await screen.findByText("저장했습니다.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
expect(await screen.findByText("Created")).toBeVisible();
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
@@ -0,0 +1,5 @@
import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.js";
export function ForbiddenTemplate(_props: { application: ApplicationApi }) {
return <main>Template must not select an application use case.</main>;
}
+6
View File
@@ -0,0 +1,6 @@
import type { PageActionDefinition } from "../../../src/presentation/templates/index.js";
export const activeButtonWithoutCallback = {
kind: "button",
label: "Unsafe active action",
} satisfies PageActionDefinition;
+1 -1
View File
@@ -1,4 +1,4 @@
import { ROUTE_RUNTIME_CONTRACT } from "../../../src/contracts/route-runtime-contract.js";
import { ROUTE_RUNTIME_CONTRACT } from "../../../src/features/installed-feature-contracts.js";
type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
+41 -39
View File
@@ -7,46 +7,48 @@ import { createApplication } from "../../src/application/create-application.js";
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort,
* navigation?: { reload(): void }
* navigation?: { reload(): void },
* featureInputs?: Readonly<Record<string, unknown>>
* }} [overrides]
*/
export function createTestApplication(overrides = {}) {
return createApplication({
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}),
refresh: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
"route-sample-resources": "assets/sample.js",
},
}),
},
navigation: overrides.navigation ?? { reload: () => {} },
});
return createApplication(
{
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
},
}),
refresh: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: {
"route-home": "assets/home.js",
},
}),
},
navigation: overrides.navigation ?? { reload: () => {} },
},
overrides.featureInputs,
);
}
+108
View File
@@ -0,0 +1,108 @@
import { z } from "zod";
import type { createHttpClient } from "../../src/adapters/http/client.js";
import { canonicalize } from "../../src/contracts/query-keys.js";
const entitySchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
})
.passthrough();
const payloadSchemas = {
EntityListPayload: z.array(entitySchema),
EntityPayload: entitySchema,
};
const requestSchemas = {
EntityListQuery: z
.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z.array(z.string().trim().min(1)).optional(),
})
.strict(),
CreateEntityCommand: z
.object({ name: z.string().trim().min(1).max(120) })
.strict(),
};
export const TEST_OPERATIONS = Object.freeze({
LIST_ENTITIES: Object.freeze({
method: "GET",
path: "/api/entities",
operationId: "LIST_ENTITIES",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "EntityListQuery",
responseSchema: "EntityListPayload",
owner: "test-fixture",
}),
CREATE_ENTITY: Object.freeze({
method: "POST",
path: "/api/entities",
operationId: "CREATE_ENTITY",
auth: "external-session",
timeoutMs: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateEntityCommand",
responseSchema: "EntityPayload",
owner: "test-fixture",
}),
});
export const entityQueryKeys = Object.freeze({
list: (filters: Readonly<Record<string, unknown>> = {}) =>
Object.freeze(["entity", 1, canonicalize(filters)]),
});
type Validation =
| Readonly<{ success: true; data: unknown }>
| Readonly<{ success: false }>;
function project(schema: z.ZodType | undefined, value: unknown): Validation {
const result = schema?.safeParse(value);
if (!result?.success) return { success: false };
return { success: true, data: structuredClone(result.data) };
}
type HttpDependencies = Parameters<typeof createHttpClient>[0];
export const TEST_HTTP_CONTRACT = Object.freeze({
getOperation(operationId: string) {
const operation =
TEST_OPERATIONS[operationId as keyof typeof TEST_OPERATIONS];
if (!operation) throw new Error(`Unknown test operation: ${operationId}`);
return operation;
},
validatePayload(schemaId: string, value: unknown) {
return project(
payloadSchemas[schemaId as keyof typeof payloadSchemas],
value,
);
},
validateRequest(schemaId: string, value: unknown) {
return project(
requestSchemas[schemaId as keyof typeof requestSchemas],
value,
);
},
mapPayload(operationId: string, payload: unknown) {
const mapOne = (value: unknown) => {
const entity = value as { id: string; name: string };
return { id: entity.id, displayName: entity.name };
};
if (operationId === "LIST_ENTITIES") {
return (payload as readonly unknown[]).map(mapOne);
}
if (operationId === "CREATE_ENTITY") return mapOne(payload);
throw new Error(`Unknown test mapper: ${operationId}`);
},
}) satisfies Pick<
HttpDependencies,
"getOperation" | "validatePayload" | "validateRequest" | "mapPayload"
>;
+13 -7
View File
@@ -4,11 +4,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
/** @type {number[]} */
let responseStatuses = [];
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
http.get("https://api.test/api/entities", () => {
const status = responseStatuses.shift() ?? 200;
if (status === 401) {
return HttpResponse.json(
@@ -37,6 +38,11 @@ afterAll(() => server.close());
const clock = { now: () => 0, sleep: async () => {} };
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
/**
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
@@ -63,13 +69,13 @@ describe("bounded 401 session recovery", () => {
recoverSession,
notifyUnauthenticated: vi.fn(),
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: true,
});
expect(recoverSession).toHaveBeenCalledTimes(1);
@@ -83,13 +89,13 @@ describe("bounded 401 session recovery", () => {
recoverSession: async () => "restored",
notifyUnauthenticated,
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_REQUIRED" },
});
@@ -104,13 +110,13 @@ describe("bounded 401 session recovery", () => {
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
}));
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
authSession: attachFailure,
clock,
});
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_INTEGRATION_FAILURE" },
});

Some files were not shown because too many files have changed in this diff Show More