Compare commits

..
87 changed files with 1881 additions and 1114 deletions
+33 -1
View File
@@ -29,10 +29,42 @@ module.exports = {
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" },
},
{
@@ -1,7 +1,7 @@
# SAMPLE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
Route ID: REFERENCE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
+10 -4
View File
@@ -109,9 +109,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 +140,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 +262,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"],
+9 -9
View File
@@ -3,17 +3,17 @@
"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"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"sample-resource-list",
"reference-resource-list",
"none"
],
"errorSurface": [
@@ -26,7 +26,7 @@
"route-examples-ui",
"route-examples-states",
"route-examples-auth",
"route-sample-resources",
"route-reference-resources",
"route-not-found"
]
},
@@ -50,7 +50,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 +66,11 @@
"ui-gallery-page",
"state-gallery-page",
"auth-example-page",
"sample-contract-page",
"reference-resource-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"searchCodec": ["none", "SampleResourceListQuery"]
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"references": [
{
@@ -82,7 +82,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 +139,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.
@@ -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와 심화 품질 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다.
@@ -70,7 +69,7 @@ recovery 계약은 구현됐다. 현재 선행 해결
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
| 인증 연동 | 준비됨/프로젝트 선택 | 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로 사용 범위 확장 |
@@ -86,7 +85,7 @@ recovery 계약은 구현됐다. 현재 선행 해결
| 비동기 상태 불변식 | 준비됨 | 배타적 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 수직 테스트가 실행된다.
#### 비동기·복구 상태의 불변식이 닫혀 있지 않다
@@ -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,14 @@ 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을 거절한다.
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다.
RP-05에서 두 번째 불일치도 닫혔다. feature별 domain/application/adapter/
contract/presentation은 `src/features/reference-feature`가 소유하고, generic
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
@@ -1222,11 +1221,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 품질
+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:
@@ -23,8 +23,8 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
- route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
`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
+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.
+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,14 @@ 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개를 다시 검증한다.
## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을
+56
View File
@@ -196,6 +196,62 @@ export default [
"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: {
+2 -1
View File
@@ -34,7 +34,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",
+1 -1
View File
@@ -13,7 +13,7 @@
"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-not-found": "src/presentation/pages/not-found-page.jsx"
}
}
+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.");
}
+185 -45
View File
@@ -1,78 +1,218 @@
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 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));
await rm(path.join(fixtureRoot, featureSource), {
recursive: true,
force: true,
});
await rm(path.join(fixtureRoot, featureTests), {
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 {{
+5 -1
View File
@@ -46,6 +46,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 +63,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 +197,7 @@ export function createHttpClient(dependencies) {
? input.body
: {};
if (operation.requestSource !== "none") {
const requestValidation = validateOperationRequest(
const requestValidation = validateRequest(
operation.requestSchema,
requestValue,
);
+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}`);
+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/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/mutation controller로 연결하는 inbound
adapter와 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 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 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,56 @@
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;
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 }>) {
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;
},
});
}
@@ -0,0 +1,58 @@
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 }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
export type ReferenceGateway = Readonly<{
list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string }>,
): 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;
},
});
}
@@ -0,0 +1,75 @@
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)]),
});
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,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
moduleId: "reference-resource-page",
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
}),
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",
}),
}),
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,54 @@
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") {
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,67 @@
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();
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),
})
.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,14 @@
import { lazy } from "react";
import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
} as const;
@@ -0,0 +1,60 @@
import { useState, type FormEvent } from "react";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { PageHeader } from "../../../presentation/components/page-header.jsx";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const { filters, query, mutation } = useReferenceFeature();
const [name, setName] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = await mutation.submit({ name });
if (result.ok) setName("");
}
return (
<section className="ui-page">
<PageHeader
eyebrow="제거 가능한 수직 슬라이스"
title="Reference feature"
description="URL codec, application input, HTTP/schema/mapper와 query/mutation 상태를 한 경로로 검증합니다."
/>
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li
key={resource.resourceId}
data-optimistic={resource.optimistic || undefined}
>
{resource.title}
</li>
))}
</ul>
</AsyncSurface>
<AsyncSurface
state={mutation.state}
onResolveConflict={mutation.resolveConflict}
>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="reference-resource-name"> </label>
<input
id="reference-resource-name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<Button type="submit" disabled={mutation.state.overlay.mutationPending}>
</Button>
</form>
</AsyncSurface>
</section>
);
}
@@ -0,0 +1,62 @@
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";
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"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
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 });
}
+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>;
@@ -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");
});
});
+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",
);
});
});
+4 -8
View File
@@ -1,14 +1,10 @@
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) =>
definition.path === "*" ? "/not-found" : definition.path,
)) {
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();
});
@@ -0,0 +1,94 @@
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",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
]);
});
});
@@ -0,0 +1,234 @@
// @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,
},
}),
...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 optimistic create and rolls back 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({
listResources: async () => ({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
}),
createResource,
}),
);
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
const submit = screen.getByRole("button", { name: "추가" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
expect(await screen.findByText("Conflicting")).toHaveAttribute(
"data-optimistic",
"true",
);
expect(screen.getByText("mutation-pending")).toBeVisible();
finish?.({
ok: false,
error: createFailure(
"CONFLICT",
"CREATE_REFERENCE_RESOURCE",
0,
),
});
expect(
await screen.findByRole("button", { name: "충돌 해결" }),
).toBeVisible();
expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
await waitFor(() =>
expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
);
});
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.type(screen.getByLabelText("새 항목 이름"), "Created");
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,129 @@
// @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-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.type(screen.getByLabelText("새 항목 이름"), " Created ");
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);
});
});
+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" },
});
+20 -14
View File
@@ -3,10 +3,11 @@ import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
let attempts = 0;
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
http.get("https://api.test/api/entities", () => {
attempts += 1;
if (attempts < 3) {
return HttpResponse.json(
@@ -34,16 +35,21 @@ const clock = {
sleep: async () => {},
};
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("shared HTTP client", () => {
it("retries a safe request at most twice and returns validated data", async () => {
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
clock,
random: () => 0,
});
await expect(
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
client.execute("LIST_ENTITIES", { routeId: "TEST_ROUTE" }),
).resolves.toMatchObject({
ok: true,
value: [{ id: "resource-1", displayName: "Example" }],
@@ -55,12 +61,12 @@ describe("shared HTTP client", () => {
it("rejects a non-JSON response without exposing its body", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
"https://api.test/api/entities",
() => new HttpResponse("<secret>raw body</secret>", { status: 502 }),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_SAMPLE_RESOURCES");
const client = testClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("LIST_ENTITIES");
expect(result).toMatchObject({
ok: false,
@@ -72,21 +78,21 @@ describe("shared HTTP client", () => {
it("classifies malformed JSON and invalid payloads at the boundary", async () => {
server.use(
http.get(
"https://api.test/api/sample/resources",
"https://api.test/api/entities",
() =>
new HttpResponse("{", {
headers: { "Content-Type": "application/json" },
}),
),
);
const client = createHttpClient({ baseUrl: "https://api.test", clock });
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
const client = testClient({ baseUrl: "https://api.test", clock });
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "MALFORMED_JSON" },
});
server.use(
http.get("https://api.test/api/sample/resources", () =>
http.get("https://api.test/api/entities", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: 42 }],
@@ -94,7 +100,7 @@ describe("shared HTTP client", () => {
}),
),
);
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "SCHEMA_MISMATCH" },
});
@@ -102,7 +108,7 @@ describe("shared HTTP client", () => {
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
http.get("https://api.test/api/sample/resources", () =>
http.get("https://api.test/api/entities", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
@@ -110,7 +116,7 @@ describe("shared HTTP client", () => {
}),
),
);
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
clock,
mapPayload: () => {
@@ -118,7 +124,7 @@ describe("shared HTTP client", () => {
},
});
const result = await client.execute("LIST_SAMPLE_RESOURCES");
const result = await client.execute("LIST_ENTITIES");
expect(result).toMatchObject({
ok: false,
error: { kind: "UNKNOWN_FAILURE" },
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import {
entityQueryKeys,
TEST_HTTP_CONTRACT,
} from "../helpers/http-contract-fixture.js";
/** @param {unknown} data */
function successResponse(data) {
@@ -40,6 +43,11 @@ function recordingScheduler() {
};
}
/** @param {Parameters<typeof createHttpClient>[0]} options */
function testClient(options) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("HTTP operation execution contract", () => {
it("sends parsed search/body values and aligns canonical query identity", async () => {
const requests = /** @type {Request[]} */ ([]);
@@ -51,7 +59,7 @@ describe("HTTP operation execution contract", () => {
}
return successResponse([]);
});
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
@@ -60,21 +68,21 @@ describe("HTTP operation execution contract", () => {
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
searchParams: filters,
});
await client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " Trimmed " },
idempotencyKey: "logical-command",
});
expect(requests[0].url).toBe(
"https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters);
expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters);
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
@@ -84,7 +92,7 @@ describe("HTTP operation execution contract", () => {
it("performs no fetch or timer work for invalid request input", async () => {
const fetcher = vi.fn();
const scheduler = recordingScheduler();
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
@@ -92,8 +100,8 @@ describe("HTTP operation execution contract", () => {
await expect(
client.execute({
operationId: "CREATE_SAMPLE_RESOURCE",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " " },
}),
).resolves.toMatchObject({
@@ -114,7 +122,7 @@ describe("HTTP operation execution contract", () => {
async (maxRetryAttempts, totalAttempts) => {
const fetcher = vi.fn(async () => failureResponse(503));
const scheduler = recordingScheduler();
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
@@ -124,8 +132,8 @@ describe("HTTP operation execution contract", () => {
await expect(
client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
}),
).resolves.toMatchObject({
ok: false,
@@ -149,15 +157,15 @@ describe("HTTP operation execution contract", () => {
);
}),
);
const client = createHttpClient({
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
maxRetryAttempts: 0,
});
const timeoutResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
scheduler.callbacks[0]();
@@ -170,8 +178,8 @@ describe("HTTP operation execution contract", () => {
const add = vi.spyOn(caller.signal, "addEventListener");
const remove = vi.spyOn(caller.signal, "removeEventListener");
const abortResult = client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
signal: caller.signal,
});
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
@@ -196,11 +204,11 @@ describe("HTTP operation execution contract", () => {
retry: "never",
requestSource: "none",
requestSchema: "unused",
responseSchema: "SampleResourcePayload",
responseSchema: "EntityPayload",
owner: "test",
});
const unsafeFetch = vi.fn(async () => failureResponse(503));
const unsafeClient = createHttpClient({
const unsafeClient = testClient({
baseUrl: "https://api.test",
fetcher: unsafeFetch,
clock: immediateClock(),
@@ -213,14 +221,14 @@ describe("HTTP operation execution contract", () => {
expect(unsafeFetch).toHaveBeenCalledOnce();
const statusFetch = vi.fn(async () => failureResponse(500));
const statusClient = createHttpClient({
const statusClient = testClient({
baseUrl: "https://api.test",
fetcher: statusFetch,
clock: immediateClock(),
});
await statusClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(statusFetch).toHaveBeenCalledOnce();
@@ -228,15 +236,15 @@ describe("HTTP operation execution contract", () => {
successResponse([{ id: "one", name: 42 }]),
);
const schemaScheduler = recordingScheduler();
const schemaClient = createHttpClient({
const schemaClient = testClient({
baseUrl: "https://api.test",
fetcher: schemaFetch,
clock: immediateClock(),
scheduler: schemaScheduler,
});
await schemaClient.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(schemaFetch).toHaveBeenCalledOnce();
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
@@ -1,53 +0,0 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js";
const server = setupServer(
http.get("https://api.test/api/sample/resources", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-1", traceId: "trace-1" },
}),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
describe("sample vertical contract fixture", () => {
it("traverses API, schema, mapper, application facade, and cache", async () => {
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const facade = createSampleFacade({
http: createHttpClient({
baseUrl: "https://api.test",
clock: { now: () => 0, sleep: async () => {} },
}),
cache,
});
await expect(facade.listResources()).resolves.toEqual({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
});
expect(cache.read(queryKeys.resource.list({}))).toMatchObject({
ok: true,
value: [{ id: "resource-1", displayName: "Example" }],
});
});
});
+20 -35
View File
@@ -6,49 +6,34 @@ import {
validateOperationRequest,
} from "../../src/adapters/http/schema-registry.js";
describe("HTTP runtime schema boundary", () => {
describe("HTTP platform schema boundary", () => {
it("rejects an invalid top-level envelope", () => {
expect(validateEnvelope({ success: true }).success).toBe(false);
});
it("rejects an invalid operation payload with safe issue metadata", () => {
const result = validateOperationPayload("SampleResourceListPayload", [
{ id: "resource-1", name: 42 },
]);
expect(result).toMatchObject({
success: false,
issues: [{ path: "0.name" }],
});
expect(JSON.stringify(result)).not.toContain("resource-1");
});
it("returns a deep-cloned additive-tolerant payload", () => {
const source = [{ id: "resource-1", name: "Example", additive: "accepted" }];
const result = validateOperationPayload("SampleResourceListPayload", source);
expect(result).toMatchObject({
it("accepts and clones a generic valid response envelope", () => {
const source = {
success: true,
data: [{ id: "resource-1", additive: "accepted" }],
});
if (!result.success) throw new Error("expected valid sample payload");
data: [],
meta: { requestId: "request-1", traceId: "trace-1" },
};
const result = validateEnvelope(source);
expect(result).toMatchObject({ success: true, data: source });
if (!result.success) throw new Error("expected valid envelope");
expect(result.data).not.toBe(source);
});
it("validates outbound commands before transport", () => {
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "" }).success,
).toBe(false);
expect(
validateOperationRequest("CreateSampleResourceCommand", { name: "Example" })
.success,
).toBe(true);
});
it("fails closed for an unregistered schema", () => {
expect(validateOperationPayload("UnknownPayload", {})).toMatchObject({
success: false,
issues: [{ code: "SCHEMA_NOT_REGISTERED" }],
it("fails closed without leaking input when a feature schema is absent", () => {
const result = validateOperationPayload("UnknownPayload", {
secret: "not-projected",
});
expect(result).toMatchObject({
success: false,
issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }],
});
expect(JSON.stringify(result)).not.toContain("not-projected");
expect(
validateOperationRequest("UnknownCommand", { name: "Example" }).success,
).toBe(false);
});
});
+5
View File
@@ -16,10 +16,15 @@ describe("application input/output boundary", () => {
"diagnostics",
"runtime",
"recovery",
"features",
]);
expect(application).not.toHaveProperty("storage");
expect(application).not.toHaveProperty("telemetry");
expect(application).not.toHaveProperty("releaseInfo");
expect(application.features.has("not-installed")).toBe(false);
expect(() => application.features.get("not-installed")).toThrow(
"Application feature is not installed",
);
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
buildId: "test-build",
releaseId: "test-release",
+3 -44
View File
@@ -1,52 +1,11 @@
import { describe, expect, it } from "vitest";
import {
mapOperationPayload,
mapResourceDto,
} from "../../src/adapters/http/resource-mapper.js";
import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js";
import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.js";
describe("DTO to model to view-model mapping", () => {
it("contains raw DTO names at the HTTP boundary", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
backendOnly: "not propagated",
});
expect(model).toEqual({
id: "resource-1",
displayName: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
});
expect(model).not.toHaveProperty("name");
expect(model).not.toHaveProperty("backendOnly");
});
it("maps operation payloads and rejects missing mappers", () => {
expect(
mapOperationPayload("LIST_SAMPLE_RESOURCES", [
{ id: "resource-1", name: "Example" },
]),
).toEqual([
{ id: "resource-1", displayName: "Example", createdAt: null },
]);
describe("platform DTO mapper boundary", () => {
it("fails closed when no feature mapper was injected", () => {
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
"No boundary mapper registered",
);
});
it("projects an application-owned render-ready shape", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: null,
});
expect(toResourceViewModel(model)).toEqual({
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
});
});
});
+1 -1
View File
@@ -49,7 +49,7 @@ describe("frontend failure classification", () => {
};
const result = createFailure(
"SERVER_FAILURE",
"LIST_SAMPLE_RESOURCES",
"LIST_ENTITIES",
0,
untrustedDetails,
);
+1 -1
View File
@@ -4,7 +4,7 @@ import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "../../scripts/lib/manual-a11y-evidence.mjs";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
const reviewed = `Status: reviewed
Route ID: APP_HOME
+16 -107
View File
@@ -1,126 +1,35 @@
import { describe, expect, it } from "vitest";
import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.js";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "../../src/presentation/routes/navigation-policy.js";
import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/contracts/routes.js";
describe("route registry", () => {
it("matches the stable registry snapshot", () => {
expect(ROUTE_REGISTRY).toMatchInlineSnapshot(`
{
"APP_HOME": {
"access": "public",
"chunkId": "route-home",
"errorSurface": "route-boundary",
"loadingSurface": "app-shell",
"navigationLabel": "시작",
"navigationOrder": 10,
"paramsSchema": null,
"path": "/",
"routeId": "APP_HOME",
"searchSchema": null,
"title": "시작",
},
"EXAMPLES_AUTH": {
"access": "public",
"chunkId": "route-examples-auth",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "인증 연동",
"navigationOrder": 40,
"paramsSchema": null,
"path": "/examples/auth",
"routeId": "EXAMPLES_AUTH",
"searchSchema": null,
"title": "인증 연동",
},
"EXAMPLES_STATES": {
"access": "public",
"chunkId": "route-examples-states",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "화면 상태",
"navigationOrder": 30,
"paramsSchema": null,
"path": "/examples/states",
"routeId": "EXAMPLES_STATES",
"searchSchema": null,
"title": "화면 상태",
},
"EXAMPLES_UI": {
"access": "public",
"chunkId": "route-examples-ui",
"errorSurface": "route-boundary",
"loadingSurface": "example-page",
"navigationLabel": "UI 구성요소",
"navigationOrder": 20,
"paramsSchema": null,
"path": "/examples/ui",
"routeId": "EXAMPLES_UI",
"searchSchema": null,
"title": "UI 구성요소",
},
"NOT_FOUND": {
"access": "public",
"chunkId": "route-not-found",
"errorSurface": "not-found",
"loadingSurface": "none",
"navigationLabel": null,
"navigationOrder": null,
"paramsSchema": "NotFoundSplat",
"path": "*",
"routeId": "NOT_FOUND",
"searchSchema": null,
"title": "페이지를 찾을 수 없음",
},
"SAMPLE_RESOURCE_LIST": {
"access": "integration-defined",
"chunkId": "route-sample-resources",
"errorSurface": "feature-boundary",
"loadingSurface": "sample-resource-list",
"navigationLabel": "보호된 연동 지점",
"navigationOrder": 50,
"paramsSchema": null,
"path": "/sample/resources",
"routeId": "SAMPLE_RESOURCE_LIST",
"searchSchema": "SampleResourceListQuery",
"title": "보호된 연동 지점",
},
}
`);
});
describe("installed route registry", () => {
it("derives visible navigation in explicit order", () => {
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual([
"APP_HOME",
"EXAMPLES_UI",
"EXAMPLES_STATES",
"EXAMPLES_AUTH",
"SAMPLE_RESOURCE_LIST",
]);
const expected = Object.values(ROUTE_REGISTRY)
.filter((route) => route.navigationOrder !== null)
.sort(
(left, right) =>
/** @type {number} */ (left.navigationOrder) -
/** @type {number} */ (right.navigationOrder),
)
.map((route) => route.routeId);
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual(expected);
});
it("treats client access as a UX hint, not authorization", () => {
it("allows public routes without consulting a product permission", () => {
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
allowed: true,
action: "none",
});
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "unauthenticated")).toEqual({
allowed: false,
action: "show-sign-in",
});
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "authenticated")).toEqual({
allowed: true,
action: "none",
});
});
it("allows at most one automatic redirect per source-target pair", () => {
it("bounds automatic redirects by pair and maximum hops", () => {
const guard = createRedirectLoopGuard(2);
expect(guard.allow("/private", "/signin")).toBe(true);
expect(guard.allow("/private", "/signin")).toBe(false);
+16 -7
View File
@@ -5,18 +5,27 @@ import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import { canonicalize } from "../../src/contracts/query-keys.js";
const queryKeys = Object.freeze({
all: () => Object.freeze(["entity", 1]),
list: (filters = {}) =>
Object.freeze(["entity", 1, "list", canonicalize(filters)]),
/** @param {string} entityId */
detail: (entityId) =>
Object.freeze(["entity", 1, "detail", String(entityId)]),
});
describe("query key registry", () => {
it("canonicalizes filter order into the same stable key", () => {
expect(queryKeys.resource.list({ page: 1, status: "open" })).toEqual(
queryKeys.resource.list({ status: "open", page: 1 }),
expect(queryKeys.list({ page: 1, status: "open" })).toEqual(
queryKeys.list({ status: "open", page: 1 }),
);
});
it("contains no raw URL or token material", () => {
expect(JSON.stringify(queryKeys.resource.detail("resource-1"))).toBe(
'["resource",1,"detail","resource-1"]',
expect(JSON.stringify(queryKeys.detail("entity-1"))).toBe(
'["entity",1,"detail","entity-1"]',
);
});
});
@@ -25,7 +34,7 @@ describe("TanStack QueryCachePort adapter", () => {
it("reads, writes, and invalidates only the declared namespace", async () => {
const client = createQueryClient();
const adapter = createQueryCacheAdapter(client);
const listKey = queryKeys.resource.list({ page: 1 });
const listKey = queryKeys.list({ page: 1 });
const otherKey = ["other", 1];
expect(adapter.write(listKey, [{ id: "resource-1" }])).toEqual({ ok: true });
@@ -35,7 +44,7 @@ describe("TanStack QueryCachePort adapter", () => {
value: [{ id: "resource-1" }],
});
await adapter.invalidate(queryKeys.resource.all());
await adapter.invalidate(queryKeys.all());
expect(client.getQueryState(listKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(otherKey)?.isInvalidated).toBe(false);
});
+8 -48
View File
@@ -1,15 +1,14 @@
import { describe, expect, it } from "vitest";
import { ROUTE_RUNTIME_CONTRACT } from "../../src/contracts/route-runtime-contract.js";
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
import {
buildRouteUrl,
parseRouteInput,
} from "../../src/presentation/routes/route-codecs.js";
import { ROUTE_RUNTIME } from "../../src/presentation/routes/route-runtime.js";
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../../src/features/installed-feature-contracts.js";
import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.js";
import { parseRouteInput } from "../../src/presentation/routes/route-codecs.js";
describe("typed route contract and runtime", () => {
it("keeps contract, runtime contribution, and executable module complete", () => {
describe("typed installed route catalog", () => {
it("keeps contract and executable runtime contributions complete", () => {
expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual(
Object.keys(ROUTE_REGISTRY).sort(),
);
@@ -18,46 +17,7 @@ describe("typed route contract and runtime", () => {
);
});
it("round-trips canonical search through the registered codec", () => {
const url = buildRouteUrl("SAMPLE_RESOURCE_LIST", {
search: {
tags: ["open", "new"],
cursor: "a/b",
limit: 5,
},
});
expect(url).toBe(
"/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
const parsedUrl = new URL(url, "https://app.test");
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
parsedUrl.searchParams,
),
).toEqual({
success: true,
data: {
routeId: "SAMPLE_RESOURCE_LIST",
params: {},
search: {
cursor: "a/b",
limit: 5,
tags: ["open", "new"],
},
},
});
});
it("rejects unknown search and accepts the not-found splat owner", () => {
expect(
parseRouteInput(
"SAMPLE_RESOURCE_LIST",
{},
new URLSearchParams("unknown=value"),
),
).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
it("accepts the platform not-found splat owner", () => {
expect(
parseRouteInput(
"NOT_FOUND",
+19 -15
View File
@@ -4,6 +4,7 @@ import {
createRuntimeAdapters,
createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.js";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
const runtime = {
config: {
@@ -118,23 +119,26 @@ describe("runtime adapter composition", () => {
release,
host: {},
})).outputPorts.session;
const client = createRuntimeHttpClient({
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
runtime
),
authSession:
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
authSession
),
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
});
const client = createRuntimeHttpClient(
{
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
runtime
),
authSession:
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
authSession
),
fetcher,
clock: { now: () => 0, sleep: async () => {} },
scheduler,
},
TEST_HTTP_CONTRACT,
);
await client.execute({
operationId: "LIST_SAMPLE_RESOURCES",
routeId: "SAMPLE_RESOURCE_LIST",
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(fetcher).toHaveBeenCalledOnce();
+1 -1
View File
@@ -13,7 +13,7 @@ const validAttributes = {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "3",
route_id: "SAMPLE_RESOURCE_LIST",
route_id: "TEST_ROUTE",
};
describe("telemetry registry and redaction", () => {