From ce0040e407622dd72ff1635320205748047ec38a Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sun, 26 Jul 2026 14:26:39 +0900 Subject: [PATCH] feat: execute route and release recovery contracts --- config/ci/gates.json | 14 +- config/contracts/registry-governance.json | 63 ++++ .../decisions/VD-03-react-router-data-mode.md | 58 ++++ .../frontend-platform-capability-review.md | 50 +-- .../frontend-ports-adapters-and-boundaries.md | 14 +- .../routing-pages-and-patterns.md | 38 +-- .../frontend-platform-testing-strategy.md | 10 +- package.json | 2 + public/release-manifest.json | 10 +- scripts/check-registries.mjs | 18 + scripts/drill-runbook.mjs | 2 + scripts/generate-build-manifest.mjs | 38 ++- scripts/verify-release.mjs | 66 +++- src/application/create-application.ts | 45 +++ src/application/ports/in/application-api.ts | 9 + .../ports/out/application-output-ports.ts | 1 + src/application/ports/release-info-port.js | 11 +- .../use-cases/decide-chunk-recovery.js | 22 +- src/bootstrap/load-release-manifest.js | 99 ++++-- src/bootstrap/runtime-adapters.js | 19 ++ src/contracts/errors.js | 35 ++ src/contracts/route-runtime-contract.js | 45 +++ src/contracts/routes.js | 2 +- .../boundaries/chunk-recovery-boundary.tsx | 97 ++++++ .../boundaries/render-error-boundary.jsx | 11 + src/presentation/components/error-copy.js | 5 + src/presentation/components/page-header.jsx | 1 - src/presentation/routes/app-router.jsx | 260 --------------- src/presentation/routes/app-router.tsx | 309 ++++++++++++++++++ src/presentation/routes/navigation-policy.js | 17 +- src/presentation/routes/route-codecs.ts | 128 ++++++++ src/presentation/routes/route-runtime.tsx | 48 +++ .../chunk-recovery-boundary.test.tsx | 66 ++++ tests/component/render-boundary.test.jsx | 30 ++ tests/component/router.test.jsx | 40 ++- tests/component/runtime-application.test.jsx | 8 + .../fixtures/registry/routes/governance.json | 59 ++++ .../registry/routes/invalid-routes.ts | 29 ++ .../typecheck/invalid-route-runtime.ts | 14 + tests/helpers/create-test-application.js | 19 +- tests/runtime-schema/release-manifest.test.js | 57 ++++ tests/unit/application-boundary.test.ts | 11 + tests/unit/chunk-recovery-runtime.test.ts | 129 ++++++++ tests/unit/chunk-recovery.test.js | 11 +- tests/unit/error-classification.test.js | 2 +- tests/unit/navigation-policy.test.js | 7 +- tests/unit/registry-governance.test.js | 6 +- tests/unit/route-runtime.test.ts | 69 ++++ tests/unit/runtime-adapters.test.js | 30 ++ 49 files changed, 1761 insertions(+), 373 deletions(-) create mode 100644 docs/architecture/decisions/VD-03-react-router-data-mode.md create mode 100644 src/contracts/route-runtime-contract.js create mode 100644 src/presentation/boundaries/chunk-recovery-boundary.tsx delete mode 100644 src/presentation/routes/app-router.jsx create mode 100644 src/presentation/routes/app-router.tsx create mode 100644 src/presentation/routes/route-codecs.ts create mode 100644 src/presentation/routes/route-runtime.tsx create mode 100644 tests/component/chunk-recovery-boundary.test.tsx create mode 100644 tests/fixtures/registry/routes/governance.json create mode 100644 tests/fixtures/registry/routes/invalid-routes.ts create mode 100644 tests/fixtures/typecheck/invalid-route-runtime.ts create mode 100644 tests/unit/chunk-recovery-runtime.test.ts create mode 100644 tests/unit/route-runtime.test.ts diff --git a/config/ci/gates.json b/config/ci/gates.json index 99afbf2..79d6678 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -79,7 +79,8 @@ { "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" }, { "script": "check:types:fixture:application-input", "expect": "fail" }, - { "script": "check:types:fixture:async-overlay", "expect": "fail" } + { "script": "check:types:fixture:async-overlay", "expect": "fail" }, + { "script": "check:types:fixture:route-runtime", "expect": "fail" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], @@ -144,13 +145,15 @@ "steps": [ { "script": "check:architecture", "expect": "pass" }, { "script": "check:registries", "expect": "pass" }, - { "script": "check:registries:fixture", "expect": "fail" } + { "script": "check:registries:fixture", "expect": "fail" }, + { "script": "check:routes:fixture", "expect": "fail" } ], "logPath": "artifacts/quality/gates/FE-GATE-010.txt", "evidence": [ "artifacts/quality/dependency-report.json", "artifacts/quality/registries.json", - "artifacts/quality/registry-fixture.json" + "artifacts/quality/registry-fixture.json", + "artifacts/quality/route-registry-fixture.json" ], "retentionClass": "merge-cycle" }, @@ -158,7 +161,10 @@ "name": "build", "steps": [{ "script": "build", "expect": "pass" }], "logPath": "artifacts/quality/gates/FE-GATE-011.txt", - "evidence": ["artifacts/release/build-manifest.json"], + "evidence": [ + "artifacts/release/build-manifest.json", + "artifacts/release/runtime-config.schema.json" + ], "retentionClass": "release-coherence" }, "FE-GATE-012": { diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 84d1480..36afe18 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -6,6 +6,37 @@ "path": "src/contracts/routes.js", "exportName": "ROUTE_REGISTRY", "owner": "feature-routing-navigation-guard-contract", + "uniqueFields": ["routeId", "path", "chunkId"], + "allowedValues": { + "paramsSchema": [null, "NotFoundSplat"], + "searchSchema": [null, "SampleResourceListQuery"], + "loadingSurface": [ + "app-shell", + "example-page", + "sample-resource-list", + "none" + ], + "errorSurface": [ + "route-boundary", + "feature-boundary", + "not-found" + ], + "chunkId": [ + "route-home", + "route-examples-ui", + "route-examples-states", + "route-examples-auth", + "route-sample-resources", + "route-not-found" + ] + }, + "references": [ + { + "field": "routeId", + "registryId": "FE-REG-ROUTE-RUNTIME", + "targetField": "routeId" + } + ], "requiredFields": [ "routeId", "path", @@ -17,6 +48,38 @@ "chunkId" ] }, + { + "registryId": "FE-REG-ROUTE-RUNTIME", + "path": "src/contracts/route-runtime-contract.js", + "exportName": "ROUTE_RUNTIME_CONTRACT", + "owner": "feature-frontend-routing-release-recovery-runtime", + "requiredFields": [ + "routeId", + "moduleId", + "paramsCodec", + "searchCodec" + ], + "uniqueFields": ["routeId", "moduleId"], + "allowedValues": { + "moduleId": [ + "home-page", + "ui-gallery-page", + "state-gallery-page", + "auth-example-page", + "sample-contract-page", + "not-found-page" + ], + "paramsCodec": ["none", "NotFoundSplat"], + "searchCodec": ["none", "SampleResourceListQuery"] + }, + "references": [ + { + "field": "routeId", + "registryId": "FE-REG-ROUTE", + "targetField": "routeId" + } + ] + }, { "registryId": "FE-REG-API", "path": "src/contracts/api-operations.js", diff --git a/docs/architecture/decisions/VD-03-react-router-data-mode.md b/docs/architecture/decisions/VD-03-react-router-data-mode.md new file mode 100644 index 0000000..a562a0f --- /dev/null +++ b/docs/architecture/decisions/VD-03-react-router-data-mode.md @@ -0,0 +1,58 @@ +# VD-03: React Router Data Mode와 서버 상태 소유권 + +- 상태: Accepted +- 결정일: 2026-07-26 +- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime` + +## 배경 + +기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한 +route registry에 params/search schema, loading/error surface, access, title, +navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이 +어긋날 수 있었다. + +이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가 +소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지 +않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할 +필요가 있다. + +## 결정 + +1. 고정된 React Router `7.18.1`의 `createBrowserRouter`와 `RouterProvider`를 + 사용하는 Data Mode를 기본값으로 채택한다. +2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다. +3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서 + route 목록을 다시 열거하지 않는다. +4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical + URL을 생성한다. +5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면 + application input 또는 query adapter 한 경로를 호출한다. +6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack + Query가 계속 소유한다. +7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는 + route/feature boundary가 소유한다. +8. Framework Mode, SSR, static generation과 router version upgrade는 별도 + dependency/architecture 브랜치에서 결정한다. + +## 검증 + +- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와 + registry gate가 모두 거절한다. +- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry + fixture로 검증한다. +- params/search parse/build round-trip, canonical redirect, 최대 redirect hop, + access rejection, title/focus와 boundary reset을 unit/component test로 검증한다. +- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를 + build/release 검증기가 확인한다. +- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만 + reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다. + +## 결과와 rollback + +Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다. +이 구분을 지키면 React Router를 교체해도 application input과 output port는 +유지된다. + +rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure +surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset +cache의 purge는 저장소 rollback 범위에 포함하지 않는다. diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index 88a4041..baac965 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -12,9 +12,9 @@ - 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다. - 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다. -검토 기준 브랜치는 `develop`, 기준 커밋은 `cb195f8`이다. 이후 구현으로 경로나 -세부 내용이 달라질 수 있으므로, 각 항목은 문서의 경로뿐 아니라 해당 테스트와 -아키텍처 게이트로 계속 검증해야 한다. +최초 검토 기준은 `develop`의 `cb195f8`이며, RP-01~RP-04 구현 결과를 이 문서에 +누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은 +문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다. ## 2. 결론 @@ -33,13 +33,14 @@ 특히 다음은 선행 해결이 필요하다. -RP-01~RP-03에서 TypeScript 도구 안전망, application runtime 주입, -query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선행 해결 +RP-01~RP-04에서 TypeScript 도구 안전망, application runtime 주입, +query/mutation inbound adapter, HTTP 실행 계약과 executable route/release +recovery 계약은 구현됐다. 현재 선행 해결 대상은 다음과 같다. -1. 선언과 실행이 일치하는 typed route 계약 -2. 전체를 제거할 수 있는 실제 reference feature -3. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 +1. 전체를 제거할 수 있는 실제 reference feature +2. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 +3. 국제화, diagnostics, optional adapter recipe와 심화 품질 게이트 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 더 정확한 표현은 다음과 같다. @@ -72,7 +73,7 @@ query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선 | 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | -| 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 | +| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | | 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 | | 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 | | 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | @@ -156,14 +157,16 @@ client를 거대한 범용 함수로 계속 확장하지 말고 transport, reque timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP 메서드보다 feature가 요구하는 gateway interface를 노출한다. -#### route registry가 실행 계약이 아니다 +#### RP-04에서 route registry를 실행 계약으로 전환 -route registry에는 `paramsSchema`, `searchSchema`, `loadingSurface`, -`errorSurface`, `chunkId`가 있지만 실제 router tree, lazy module, navigation -목록은 별도로 작성된다. 여러 필드는 선언만 되고 런타임에 사용되지 않는다. -chunk recovery use case와 redirect loop guard도 실제 route flow에 연결되지 않는다. +`src/contracts/routes.js`와 직렬화 가능한 +`src/contracts/route-runtime-contract.js`를 기준으로 +`src/presentation/routes/app-router.tsx`가 Data Router route object와 +navigation을 생성한다. `route-runtime.tsx`는 lazy component의 실행 map만 +소유하며 contract/runtime 누락과 orphan은 TypeScript negative fixture와 registry +gate가 모두 거절한다. -목표 상태: +현재 보장: - serializable contract와 executable runtime map을 분리한다. - `satisfies Record`로 양방향 완전성을 검사한다. @@ -171,8 +174,10 @@ chunk recovery use case와 redirect loop guard도 실제 route flow에 연결되 사용한다. - loading/error/chunk/access/title/navigation metadata를 실제 route object에 연결한다. -- route change 시 boundary reset, focus, scroll, navigation cancellation을 - 검증한다. +- route change 시 boundary reset, title, focus와 scroll을 검증한다. +- Vite manifest의 실제 dynamic entry와 route chunk ID를 release manifest에 + 연결하고, no-store manifest 재조회와 build/release 쌍별 1회 reload를 + production application input까지 연결한다. #### reference feature가 완전히 제거되지 않는다 @@ -204,9 +209,11 @@ mutation-pending mutation-conflict ``` -chunk recovery와 release coherence도 policy 함수가 존재하는 것으로 완료되지 -않는다. 실제 lazy import failure가 manifest 재확인, build 비교, 단 한 번의 guarded -reload, 반복 실패 지원 표면까지 이어지고 E2E로 검증되어야 한다. +RP-04에서 lazy import failure는 `ChunkRecoveryBoundary` → application recovery +input → `ReleaseInfoPort.refresh()`의 no-store manifest 조회 → build/release 쌍 +guard → browser navigation adapter의 1회 reload로 연결됐다. 일반 render +failure는 이 경로에서 제외되고, 반복 실패·offline·malformed manifest·storage +실패는 지원 표면으로 fail-closed된다. #### telemetry, registry, 공급망 gate의 실행 깊이가 부족하다 @@ -378,7 +385,8 @@ tree-shakable SVG icon source로 적절하지만 select, dialog, menu, focus man - TS source와 test 전체 typecheck - 실제 composition root부터 page까지의 통합 - query/mutation controller와 optimistic rollback -- route registry/runtime map 정합성 +- route registry/runtime map 정합성은 RP-04에서 unit, component, negative + registry/type fixture와 built artifact 검증으로 구현됨 - runtime timeout/retry와 path/query/parsed body - shared MSW scenario catalog - isolated component stories와 interaction test diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 9f4e58d..fa86802 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -154,11 +154,13 @@ RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다. - HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 비활성화한다. +RP-04에서 첫 번째 실행 불일치는 닫혔다. route registry와 runtime map은 +Data Router tree, codec, surface, title, navigation, chunk/release recovery의 +단일 조립 입력이며 registry/type/build 검증이 누락과 orphan을 거절한다. + 후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다. -1. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, - `errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. -2. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API +1. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API operation, Zod schema, mapper, domain model과 query key는 다른 경로에 남는다. @@ -1212,9 +1214,9 @@ contract와 실패 분기를 우선한다. ### 26.4 Routing과 상태 -- [ ] route registry와 실행 route tree가 동일 source에서 생성된다. -- [ ] params/search schema가 실제 navigation에서 실행된다. -- [ ] loading/error/chunk/access metadata가 실행 behavior와 연결된다. +- [x] route registry와 실행 route tree가 동일 source에서 생성된다. +- [x] params/search schema가 실제 navigation에서 실행된다. +- [x] loading/error/chunk/access metadata가 실행 behavior와 연결된다. - [ ] local, URL, server, session, persisted state가 분류 규칙을 따른다. - [ ] server state를 별도 global store에 중복 보관하지 않는다. diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md index 8f4bb80..4d4e71b 100644 --- a/docs/architecture/routing-pages-and-patterns.md +++ b/docs/architecture/routing-pages-and-patterns.md @@ -14,24 +14,22 @@ ## 2. 현재 상태와 문제 -현재 구현에는 다음 장점이 있다. +RP-04 이후 현재 구현에는 다음 장점이 있다. - route registry가 path와 access policy를 소유한다. -- route component를 lazy import한다. +- contract에서 Data Router route object와 navigation을 생성한다. +- runtime map이 route component를 lazy import하고 codec을 연결한다. - 앱 셸과 보호 route, not-found surface가 있다. - route heading focus와 비동기/render error boundary가 있다. -- redirect loop와 chunk recovery에 대한 policy 함수가 일부 존재한다. +- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다. -하지만 `src/contracts/routes.js`의 metadata와 -`src/presentation/routes/app-router.jsx`의 executable route tree가 별도 수동 목록이다. -그 결과 다음 필드는 선언돼도 실제 행동을 보장하지 않는다. - -- params/search schema -- loading/error surface -- chunk ID -- route title/navigation label -- redirect loop guard -- chunk recovery policy +`src/contracts/routes.js`, `src/contracts/route-runtime-contract.js`, +`src/presentation/routes/route-runtime.tsx`의 완전성은 TypeScript와 registry +negative fixture가 함께 검사한다. params/search codec, loading/error surface, +access, title, navigation, chunk ID는 +`src/presentation/routes/app-router.tsx`에서 모두 소비된다. built Vite +manifest의 dynamic entry는 release manifest route chunk map과 검증되며, +`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다. 페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록, 상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시 @@ -50,15 +48,14 @@ selector를 `7.18.1`로 맞춰 확인한다. | mode | 선택 조건 | 이 저장소에서의 판단 | | --- | --- | --- | -| Declarative | React composition과 외부 data layer가 route data를 소유 | 현재 구현이 사용 중인 기준선 | -| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | 목표 skeleton의 navigation lifecycle에 적합 | +| Declarative | React composition과 외부 data layer가 route data를 소유 | RP-04 이전 기준선 | +| Data | route object, blocker, scroll restoration, pending/navigation state가 필요 | VD-03으로 채택하고 RP-04에서 구현 | | Framework | route module, type-safe href, code splitting, SSR/static 전략을 framework가 소유 | client-only skeleton 기본값으로는 범위가 큼 | -목표 결정: +채택한 결정: - client-only SPA와 TanStack Query/application use case를 유지한다. -- 현재 `BrowserRouter` 기반 Declarative Mode에서 `createBrowserRouter`와 - `RouterProvider` 기반 Data Mode로 이동한다. +- `createBrowserRouter`와 `RouterProvider` 기반 Data Mode를 사용한다. - Data Mode를 선택하는 이유는 route object, navigation blocker, scroll restoration, route error 경계를 일관되게 소유하기 위해서다. loader/action으로 서버 상태를 다시 소유하기 위해서가 아니다. @@ -68,8 +65,9 @@ selector를 `7.18.1`로 맞춰 확인한다. - SSR/static generation을 선택하기 전에는 Framework Mode를 기본값으로 만들지 않는다. -전환 브랜치 전까지 현재 Declarative router에 새 custom scroll/blocker -implementation을 추가하지 않는다. 전환할 수 없는 프로젝트만 별도 ADR과 +결정 근거와 rollback 경계는 +`docs/architecture/decisions/VD-03-react-router-data-mode.md`에 고정한다. +Data Mode를 사용할 수 없는 프로젝트만 별도 ADR과 `NavigationLifecycleAdapter`를 구현한다. ## 4. route 계약과 runtime map diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index babb4a5..ddf862e 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -370,10 +370,9 @@ QueryClientProvider -> AppShell ``` -04 routing branch에서 Data Mode로 전환할 때 `BrowserRouter`를 -`RouterProvider`로 바꾸고 테스트 fixture도 같은 composition factory에서 -생성한다. 문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 말고 -production composition 함수를 호출한다. +RP-04에서 `RouterProvider` 기반 Data Mode로 전환했다. router component test와 +runtime composition test는 production `AppRouter`와 composition 함수를 사용하며, +문서에 적힌 provider 순서를 테스트 전용 shell로 재현하지 않는다. ### 6.3 Boot E2E @@ -1273,7 +1272,8 @@ CI registry에 추가한다. 3. MSW handlers/scenario/factory를 중앙 catalog로 이동한다. 4. query/mutation presentation adapter와 integration harness를 만든다. 5. form foundation과 form/controller test matrix를 만든다. -6. route registry/runtime map contract와 built-dist E2E를 추가한다. +6. route registry/runtime map contract와 built-dist artifact 검증을 유지하고, + release server를 사용하는 built-dist E2E까지 확장한다. 7. Storybook build, interaction, a11y gate를 추가한다. 8. pinned Chromium visual baseline을 추가한다. 9. critical flow의 3-engine release profile을 분리한다. diff --git a/package.json b/package.json index c152736..0191f54 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", "check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", "check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", + "check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", @@ -40,6 +41,7 @@ "check:browser-security": "node scripts/check-browser-security.mjs", "check:registries": "node scripts/check-registries.mjs", "check:registries:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/forbidden/governance.json --artifact artifacts/quality/registry-fixture.json", + "check:routes:fixture": "node scripts/check-registries.mjs --governance tests/fixtures/registry/routes/governance.json --artifact artifacts/quality/route-registry-fixture.json", "verify:compatibility": "node scripts/check-compatibility.mjs", "verify:release": "node scripts/verify-release.mjs", "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs", diff --git a/public/release-manifest.json b/public/release-manifest.json index f316fcd..051a15f 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -7,5 +7,13 @@ "apiContractVersion": "1", "assetManifestHash": "generated-during-build", "releaseId": "local-release", - "builtAt": "1970-01-01T00:00:00.000Z" + "builtAt": "1970-01-01T00:00:00.000Z", + "routeChunks": { + "route-home": "src/presentation/pages/home-page.jsx", + "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-not-found": "src/presentation/pages/not-found-page.jsx" + } } diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs index ea9d40a..60692da 100644 --- a/scripts/check-registries.mjs +++ b/scripts/check-registries.mjs @@ -103,6 +103,24 @@ for (const specification of governance.registries) { } } + for (const [field, allowed] of Object.entries( + specification.allowedValues ?? {}, + )) { + for (const [rowName, row] of Object.entries(rows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + if ( + !allowed.some( + /** @param {unknown} value */ + (value) => Object.is(value, row[field]), + ) + ) { + failures.push( + `${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`, + ); + } + } + } + snapshots.push({ registryId: specification.registryId, owner: specification.owner, diff --git a/scripts/drill-runbook.mjs b/scripts/drill-runbook.mjs index 08bacb4..bba52f2 100644 --- a/scripts/drill-runbook.mjs +++ b/scripts/drill-runbook.mjs @@ -123,6 +123,8 @@ async function drillChunkMismatch() { failureKind: "DEPLOY_MISMATCH", manifestLoaded: true, currentBuildId: "build-a", + currentReleaseId: "release-a", + activeBuildId: "build-b", activeReleaseId: "release-b", storage, }; diff --git a/scripts/generate-build-manifest.mjs b/scripts/generate-build-manifest.mjs index 1f3df8f..b6a5ff8 100644 --- a/scripts/generate-build-manifest.mjs +++ b/scripts/generate-build-manifest.mjs @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; 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 { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); const packageManagerVersion = packageJson.packageManager.split("@").at(-1); @@ -9,11 +14,31 @@ const commitSha = process.env.VITE_COMMIT_SHA ?? "local"; const releaseId = process.env.RELEASE_ID ?? "local-release"; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; const builtAt = new Date().toISOString(); -const viteManifest = await readFile("dist/.vite/manifest.json"); +const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); +const viteManifestObject = + /** @type {Record} */ ( + JSON.parse(viteManifest) + ); const assetManifestHash = createHash("sha256") .update(viteManifest) .digest("hex"); const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); +/** @type {Record} */ +const routeChunks = {}; +for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = + /** @type {Record} */ ( + ROUTE_RUNTIME_CONTRACT + )[definition.routeId]; + const asset = Object.values(viteManifestObject).find( + (entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry, + ); + if (!runtime || !asset?.file) { + throw new Error(`Missing built route chunk: ${definition.routeId}`); + } + routeChunks[definition.chunkId] = asset.file; +} +const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema); runtimeConfig.BUILD_ID = buildId; runtimeConfig.RELEASE_ID = releaseId; @@ -31,6 +56,8 @@ const manifest = { outputs: { directory: "dist", viteManifest: "dist/.vite/manifest.json", + routeChunks, + runtimeConfigSchema: "dist/runtime-config.schema.json", }, }; @@ -44,6 +71,7 @@ const releaseManifest = { assetManifestHash, releaseId, builtAt, + routeChunks, }; await mkdir("artifacts/release", { recursive: true }); @@ -52,6 +80,14 @@ await writeFile( "dist/release-manifest.json", `${JSON.stringify(releaseManifest, null, 2)}\n`, ); +await writeFile( + "dist/runtime-config.schema.json", + `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, +); +await writeFile( + "artifacts/release/runtime-config.schema.json", + `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, +); await writeFile( "artifacts/release/build-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`, diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 6137434..ca5e4fe 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -6,6 +6,8 @@ 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"; const fixturesDocument = /** @type {{ @@ -34,7 +36,17 @@ const fixturesDocument = ); const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8")); const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); -const viteManifest = await readFile("dist/.vite/manifest.json"); +const buildManifest = JSON.parse( + await readFile("artifacts/release/build-manifest.json", "utf8"), +); +const runtimeConfigJsonSchema = JSON.parse( + await readFile("dist/runtime-config.schema.json", "utf8"), +); +const viteManifest = await readFile("dist/.vite/manifest.json", "utf8"); +const viteManifestObject = + /** @type {Record} */ ( + JSON.parse(viteManifest) + ); const actualAssetManifestHash = createHash("sha256") .update(viteManifest) .digest("hex"); @@ -52,6 +64,58 @@ if (!Number.isFinite(Date.parse(release.builtAt))) { if (release.assetManifestHash !== actualAssetManifestHash) { artifactMismatches.push("assetManifestContent"); } +if ( + runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" || + runtimeConfigJsonSchema.type !== "object" || + !runtimeConfigJsonSchema.properties +) { + artifactMismatches.push("runtimeConfigSchema"); +} +if ( + buildManifest.outputs?.runtimeConfigSchema !== + "dist/runtime-config.schema.json" +) { + artifactMismatches.push("buildManifest:runtimeConfigSchema"); +} + +const expectedChunkIds = new Set( + Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId), +); +const actualChunkIds = new Set(Object.keys(release.routeChunks ?? {})); +for (const chunkId of expectedChunkIds) { + if (!actualChunkIds.has(chunkId)) { + artifactMismatches.push(`routeChunk:missing:${chunkId}`); + } +} +for (const chunkId of actualChunkIds) { + if (!expectedChunkIds.has(chunkId)) { + artifactMismatches.push(`routeChunk:orphan:${chunkId}`); + } +} +for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = + /** @type {Record} */ ( + ROUTE_RUNTIME_CONTRACT + )[definition.routeId]; + const viteEntry = Object.values(viteManifestObject).find( + (entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry, + ); + const routeAsset = release.routeChunks?.[definition.chunkId]; + if (!runtime || !viteEntry || routeAsset !== viteEntry.file) { + artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`); + continue; + } + if ( + buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset + ) { + artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`); + } + try { + await readFile(`dist/${routeAsset}`); + } catch { + artifactMismatches.push(`routeChunk:file:${definition.chunkId}`); + } +} const fixtures = fixturesDocument.fixtures.map((fixture) => { const result = verifyCompatibilityTuple({ diff --git a/src/application/create-application.ts b/src/application/create-application.ts index d3910bd..2b92f9d 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -5,6 +5,7 @@ import type { RenderFailureReport, } from "./ports/in/application-api.js"; import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js"; +import { decideChunkRecovery } from "./use-cases/decide-chunk-recovery.js"; export type { ApplicationApi, ApplicationOutputPorts }; @@ -64,10 +65,54 @@ export function createApplication( }, }); + const recovery = Object.freeze({ + async recoverChunk(input: { + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH"; + }) { + try { + const current = await outputPorts.releaseInfo.getCurrent(); + const active = await outputPorts.releaseInfo.refresh(); + if (!active.routeChunks[input.chunkId]) { + return { + action: "support" as const, + reason: "active-chunk-unknown", + }; + } + const decision = decideChunkRecovery({ + failureKind: input.failureKind, + manifestLoaded: true, + currentBuildId: current.buildId, + currentReleaseId: current.releaseId, + activeBuildId: active.buildId, + activeReleaseId: active.releaseId, + storage: outputPorts.preferences, + }); + if (decision.action === "reload-once") { + try { + outputPorts.navigation.reload(); + } catch { + return { + action: "support" as const, + reason: "reload-failed", + }; + } + } + return decision; + } catch { + return { + action: "support" as const, + reason: "manifest-unavailable", + }; + } + }, + }); + return Object.freeze({ session, preferences, diagnostics, runtime, + recovery, }); } diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts index 0d7e108..5a79e4c 100644 --- a/src/application/ports/in/application-api.ts +++ b/src/application/ports/in/application-api.ts @@ -38,4 +38,13 @@ export type ApplicationApi = Readonly<{ runtime: Readonly<{ getReleaseSummary(): Promise; }>; + recovery: Readonly<{ + recoverChunk(input: Readonly<{ + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE" | "DEPLOY_MISMATCH"; + }>): Promise< + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }> + >; + }>; }>; diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts index 85b44bf..4d39fb8 100644 --- a/src/application/ports/out/application-output-ports.ts +++ b/src/application/ports/out/application-output-ports.ts @@ -15,4 +15,5 @@ export type ApplicationOutputPorts = Readonly<{ preferences: StoragePort; diagnostics: TelemetryPort; releaseInfo: ReleaseInfoPort; + navigation: Readonly<{ reload(): void }>; }>; diff --git a/src/application/ports/release-info-port.js b/src/application/ports/release-info-port.js index cb291bd..a28a91d 100644 --- a/src/application/ports/release-info-port.js +++ b/src/application/ports/release-info-port.js @@ -9,7 +9,16 @@ * apiContractVersion: string, * assetManifestHash: string, * releaseId: string, - * builtAt?: string + * builtAt?: string, + * routeChunks: Record + * }>, + * refresh(): Promise<{ + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string, + * routeChunks: Record * }> * }} ReleaseInfoPort */ diff --git a/src/application/use-cases/decide-chunk-recovery.js b/src/application/use-cases/decide-chunk-recovery.js index a2db87e..f19201f 100644 --- a/src/application/use-cases/decide-chunk-recovery.js +++ b/src/application/use-cases/decide-chunk-recovery.js @@ -1,13 +1,21 @@ const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); +/** + * @typedef {{action: "reload-once", releasePair: string} | + * {action: "support", reason: string}} ChunkRecoveryDecision + */ + /** * @param {{ * failureKind: string, * manifestLoaded: boolean, * currentBuildId: string, + * currentReleaseId: string, + * activeBuildId: string, * activeReleaseId: string, * storage: import("../ports/storage-port.js").StoragePort * }} input + * @returns {ChunkRecoveryDecision} */ export function decideChunkRecovery(input) { if (!RECOVERABLE_KINDS.has(input.failureKind)) { @@ -16,13 +24,21 @@ export function decideChunkRecovery(input) { if (!input.manifestLoaded) { return { action: "support", reason: "manifest-unavailable" }; } - if (input.activeReleaseId === input.currentBuildId) { + if ( + input.activeBuildId === input.currentBuildId && + input.activeReleaseId === input.currentReleaseId + ) { return { action: "support", reason: "same-release" }; } - const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`; + const releasePair = + `${input.currentBuildId}/${input.currentReleaseId}` + + `->${input.activeBuildId}/${input.activeReleaseId}`; const guard = input.storage.read("CHUNK_RELOAD_GUARD"); - if (!guard.ok || guard.value === releasePair) { + if (!guard.ok) { + return { action: "support", reason: "guard-read-failed" }; + } + if (guard.value === releasePair) { return { action: "support", reason: "reload-already-attempted" }; } diff --git a/src/bootstrap/load-release-manifest.js b/src/bootstrap/load-release-manifest.js index aea1dcd..e0dd7c0 100644 --- a/src/bootstrap/load-release-manifest.js +++ b/src/bootstrap/load-release-manifest.js @@ -1,7 +1,7 @@ import { z } from "zod"; const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); -const releaseManifestSchema = z +export const releaseManifestSchema = z .object({ schemaVersion: z.literal(1), appVersion: z.string().min(1), @@ -12,6 +12,7 @@ const releaseManifestSchema = z assetManifestHash: z.string().min(1), releaseId: z.string().min(1), builtAt: z.string().min(1), + routeChunks: z.record(z.string().min(1), z.string().min(1)), }) .strict(); @@ -20,7 +21,14 @@ export class ReleaseManifestError extends Error { constructor(code, safe) { super("Release manifest could not be loaded"); this.name = "ReleaseManifestError"; - this.kind = "RELEASE_MANIFEST_FAILURE"; + this.kind = + { + MANIFEST_BUILD_MISMATCH: "BUILD_MISMATCH", + MANIFEST_CONFIG_SCHEMA_MISMATCH: "CONFIG_MISMATCH", + MANIFEST_API_CONTRACT_MISMATCH: "API_CONTRACT_MISMATCH", + MANIFEST_RELEASE_MISMATCH: "RELEASE_MISMATCH", + MANIFEST_ASSET_MISMATCH: "ASSET_MISMATCH", + }[code] ?? "RELEASE_MANIFEST_FAILURE"; this.code = code; this.safe = Object.freeze({ kind: this.kind, @@ -33,75 +41,98 @@ export class ReleaseManifestError extends Error { } /** - * @param {Awaited>} runtime - * @param {{fetcher?: typeof fetch}} [options] + * Fetches and validates the active manifest without imposing the current + * build tuple. Chunk recovery uses this no-store view to detect a new release. + * + * @param {string} url + * @param {{ + * fetcher?: typeof fetch, + * buildId: string, + * releaseId?: string + * }} options */ -export async function loadReleaseManifest(runtime, options = {}) { +export async function fetchReleaseManifest(url, options) { const fetcher = options.fetcher ?? fetch; let response; try { - response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, { + response = await fetcher(url, { cache: "no-store", headers: { Accept: "application/json" }, }); } catch { - throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", { - buildId: runtime.build.buildId, - releaseId: runtime.config.RELEASE_ID, - }); + throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", options); } if (!response.ok) { - throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", { - buildId: runtime.build.buildId, - releaseId: runtime.config.RELEASE_ID, - }); + throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", options); } - let raw; try { raw = await response.json(); } catch { - throw new ReleaseManifestError("MANIFEST_JSON_INVALID", { - buildId: runtime.build.buildId, - releaseId: runtime.config.RELEASE_ID, - }); + throw new ReleaseManifestError("MANIFEST_JSON_INVALID", options); } const parsed = releaseManifestSchema.safeParse(raw); if (!parsed.success) { - throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", { + throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", options); + } + return Object.freeze(structuredClone(parsed.data)); +} + +/** + * @param {Awaited>} runtime + * @param {{fetcher?: typeof fetch, expectedAssetManifestHash?: string}} [options] + */ +export async function loadReleaseManifest(runtime, options = {}) { + const manifest = await fetchReleaseManifest( + runtime.config.RELEASE_MANIFEST_URL, + { + fetcher: options.fetcher, buildId: runtime.build.buildId, releaseId: runtime.config.RELEASE_ID, - }); + }, + ); + let mismatchCode = null; + if (manifest.buildId !== runtime.build.buildId) { + mismatchCode = "MANIFEST_BUILD_MISMATCH"; } - - const manifest = parsed.data; - const mismatches = []; - if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId"); if ( + !mismatchCode && runtime.config.BUILD_ID && manifest.buildId !== runtime.config.BUILD_ID ) { - mismatches.push("runtimeBuildId"); + mismatchCode = "MANIFEST_BUILD_MISMATCH"; } if ( + !mismatchCode && manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION ) { - mismatches.push("configSchemaVersion"); - } - if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) { - mismatches.push("apiContractVersion"); + mismatchCode = "MANIFEST_CONFIG_SCHEMA_MISMATCH"; } if ( + !mismatchCode && + manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION + ) { + mismatchCode = "MANIFEST_API_CONTRACT_MISMATCH"; + } + if ( + !mismatchCode && runtime.config.RELEASE_ID && manifest.releaseId !== runtime.config.RELEASE_ID ) { - mismatches.push("releaseId"); + mismatchCode = "MANIFEST_RELEASE_MISMATCH"; } - if (mismatches.length > 0) { - throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", { + if ( + !mismatchCode && + options.expectedAssetManifestHash && + manifest.assetManifestHash !== options.expectedAssetManifestHash + ) { + mismatchCode = "MANIFEST_ASSET_MISMATCH"; + } + if (mismatchCode) { + throw new ReleaseManifestError(mismatchCode, { buildId: runtime.build.buildId, releaseId: runtime.config.RELEASE_ID, }); } - return Object.freeze(structuredClone(manifest)); + return manifest; } diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js index 835b65c..346d052 100644 --- a/src/bootstrap/runtime-adapters.js +++ b/src/bootstrap/runtime-adapters.js @@ -9,6 +9,7 @@ import { } from "../adapters/query-cache/tanstack-query-cache.js"; 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"; /** * @param {Record} host @@ -97,6 +98,23 @@ export async function createRuntimeAdapters(context) { async getCurrent() { return structuredClone(context.release); }, + async refresh() { + return fetchReleaseManifest(config.RELEASE_MANIFEST_URL, { + fetcher: context.fetcher, + buildId: context.release.buildId, + releaseId: context.release.releaseId, + }); + }, + }); + const navigation = Object.freeze({ + reload() { + const location = + /** @type {{reload?: () => void} | undefined} */ (host.location); + if (typeof location?.reload !== "function") { + throw new Error("Browser reload is unavailable"); + } + location.reload(); + }, }); return Object.freeze({ @@ -105,6 +123,7 @@ export async function createRuntimeAdapters(context) { preferences: storage, diagnostics: telemetry, releaseInfo, + navigation, }), infrastructure: Object.freeze({ queryClient, diff --git a/src/contracts/errors.js b/src/contracts/errors.js index 8c0af28..40d924f 100644 --- a/src/contracts/errors.js +++ b/src/contracts/errors.js @@ -111,6 +111,41 @@ export const ERROR_REGISTRY = Object.freeze({ "reload-once", "release.mismatch.detected", ), + BUILD_MISMATCH: row( + "BUILD_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + CONFIG_MISMATCH: row( + "CONFIG_MISMATCH", + false, + "error", + "contact-support", + "app.boot.failed", + ), + API_CONTRACT_MISMATCH: row( + "API_CONTRACT_MISMATCH", + false, + "error", + "contact-support", + "app.boot.failed", + ), + RELEASE_MISMATCH: row( + "RELEASE_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), + ASSET_MISMATCH: row( + "ASSET_MISMATCH", + false, + "error", + "reload-once", + "release.mismatch.detected", + ), STORAGE_UNAVAILABLE: row( "STORAGE_UNAVAILABLE", false, diff --git a/src/contracts/route-runtime-contract.js b/src/contracts/route-runtime-contract.js new file mode 100644 index 0000000..19e149f --- /dev/null +++ b/src/contracts/route-runtime-contract.js @@ -0,0 +1,45 @@ +/** + * @typedef {"none" | "NotFoundSplat" | "SampleResourceListQuery"} 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({ + APP_HOME: runtime({ + routeId: "APP_HOME", + moduleId: "home-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_UI: runtime({ + routeId: "EXAMPLES_UI", + moduleId: "ui-gallery-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_STATES: runtime({ + routeId: "EXAMPLES_STATES", + moduleId: "state-gallery-page", + paramsCodec: "none", + searchCodec: "none", + }), + EXAMPLES_AUTH: runtime({ + routeId: "EXAMPLES_AUTH", + moduleId: "auth-example-page", + 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", + paramsCodec: "NotFoundSplat", + searchCodec: "none", + }), +}); diff --git a/src/contracts/routes.js b/src/contracts/routes.js index 32f4276..050bb06 100644 --- a/src/contracts/routes.js +++ b/src/contracts/routes.js @@ -86,7 +86,7 @@ export const ROUTE_REGISTRY = Object.freeze({ NOT_FOUND: route({ routeId: "NOT_FOUND", path: "*", - paramsSchema: null, + paramsSchema: "NotFoundSplat", searchSchema: null, access: "public", loadingSurface: "none", diff --git a/src/presentation/boundaries/chunk-recovery-boundary.tsx b/src/presentation/boundaries/chunk-recovery-boundary.tsx new file mode 100644 index 0000000..7b47d2d --- /dev/null +++ b/src/presentation/boundaries/chunk-recovery-boundary.tsx @@ -0,0 +1,97 @@ +import { + Component, + type ErrorInfo, + type ReactNode, +} from "react"; + +type RecoveryResult = + | Readonly<{ action: "reload-once"; releasePair: string }> + | Readonly<{ action: "support"; reason: string }>; + +type Props = Readonly<{ + children: ReactNode; + chunkId: string; + recover(input: Readonly<{ + chunkId: string; + failureKind: "CHUNK_LOAD_FAILURE"; + }>): Promise; +}>; + +type State = Readonly<{ + error: unknown | null; + recovery: "idle" | "checking" | "reload-requested" | "support"; + reason?: string; +}>; + +export function isChunkLoadFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const value = `${error.name} ${error.message}`.toLowerCase(); + return ( + value.includes("chunkloaderror") || + value.includes("loading chunk") || + value.includes("dynamically imported module") || + value.includes("failed to fetch module script") + ); +} + +export class ChunkRecoveryBoundary extends Component { + state: State = { error: null, recovery: "idle" }; + + static getDerivedStateFromError(error: unknown): State { + return { error, recovery: "checking" }; + } + + componentDidCatch(error: unknown, _info: ErrorInfo) { + if (!isChunkLoadFailure(error)) return; + void this.props + .recover({ + chunkId: this.props.chunkId, + failureKind: "CHUNK_LOAD_FAILURE", + }) + .then((result) => { + this.setState({ + error, + recovery: + result.action === "reload-once" + ? "reload-requested" + : "support", + ...(result.action === "support" ? { reason: result.reason } : {}), + }); + }) + .catch(() => { + this.setState({ + error, + recovery: "support", + reason: "recovery-controller-failed", + }); + }); + } + + render() { + const { error, recovery, reason } = this.state; + if (error && !isChunkLoadFailure(error)) throw error; + if (error && recovery === "checking") { + return ( +
+ 새 릴리스 정보를 확인하고 있습니다. +
+ ); + } + if (error && recovery === "reload-requested") { + return ( +
+ 새 버전으로 한 번만 전환합니다. +
+ ); + } + if (error && recovery === "support") { + return ( +
+

화면 자산을 복구하지 못했습니다.

+

문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.

+
+ ); + } + return this.props.children; + } +} diff --git a/src/presentation/boundaries/render-error-boundary.jsx b/src/presentation/boundaries/render-error-boundary.jsx index c10ecb8..b7ccd8a 100644 --- a/src/presentation/boundaries/render-error-boundary.jsx +++ b/src/presentation/boundaries/render-error-boundary.jsx @@ -6,6 +6,7 @@ import { Component } from "react"; * boundaryName: string, * routeId: string, * buildId: string, + * resetKey?: string, * onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void, * fallback?: React.ReactNode * }} RenderBoundaryProps @@ -37,6 +38,16 @@ export class RenderErrorBoundary extends Component { } } + /** @param {RenderBoundaryProps} previous */ + componentDidUpdate(previous) { + if ( + this.state.hasError && + previous.resetKey !== this.props.resetKey + ) { + this.setState({ hasError: false }); + } + } + reset = () => { this.setState({ hasError: false }); }; diff --git a/src/presentation/components/error-copy.js b/src/presentation/components/error-copy.js index 504e3af..67b8580 100644 --- a/src/presentation/components/error-copy.js +++ b/src/presentation/components/error-copy.js @@ -8,6 +8,11 @@ const ERROR_MESSAGES = Object.freeze({ "error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.", "error.server_failure": "요청을 완료하지 못했습니다.", "error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.", + "error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.", + "error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.", + "error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.", + "error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.", + "error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.", "error.render_failure": "화면을 표시하지 못했습니다.", "error.unknown_failure": "예상하지 못한 문제가 발생했습니다.", }); diff --git a/src/presentation/components/page-header.jsx b/src/presentation/components/page-header.jsx index c930d16..1a06fa8 100644 --- a/src/presentation/components/page-header.jsx +++ b/src/presentation/components/page-header.jsx @@ -11,7 +11,6 @@ export function PageHeader({ title, description, eyebrow }) { const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null)); useEffect(() => { - document.title = `${title} · Frontend Skeleton`; headingRef.current?.focus(); }, [title]); diff --git a/src/presentation/routes/app-router.jsx b/src/presentation/routes/app-router.jsx deleted file mode 100644 index 8aee8fa..0000000 --- a/src/presentation/routes/app-router.jsx +++ /dev/null @@ -1,260 +0,0 @@ -import { lazy, Suspense, useState } from "react"; -import { - BrowserRouter, - Route, - Routes, - useLocation, -} from "react-router-dom"; - -import { getRoute, routePath } from "../../contracts/routes.js"; -import { RouteBoundary } from "../boundaries/render-error-boundary.jsx"; -import { AppShell } from "../layouts/app-shell.jsx"; -import { PageHeader } from "../components/page-header.jsx"; -import { useApplication } from "../providers/application-provider.js"; -import { SessionProvider, useSession } from "../providers/session-provider.jsx"; -import { ThemeProvider } from "../providers/theme-provider.jsx"; -import { decideRouteAccess } from "./navigation-policy.js"; - -const HomePage = lazy(() => import("../pages/home-page.jsx")); -const UiGalleryPage = lazy(() => import("../examples/ui-gallery-page.jsx")); -const StateGalleryPage = lazy( - () => import("../examples/state-gallery-page.jsx"), -); -const AuthExamplePage = lazy( - () => import("../examples/auth-example-page.jsx"), -); -const SampleContractPage = lazy( - () => import("../pages/sample-contract-page.jsx"), -); -const NotFoundPage = lazy(() => import("../pages/not-found-page.jsx")); - -/** @param {{ routeId: string }} props */ -function RouteLoadingSurface({ routeId }) { - const definition = getRoute(routeId); - return ( -
-
- ); -} - -function RouteFailureSurface() { - return ( -
- -
- ); -} - -/** - * @param {{ - * routeId: string, - * buildId: string, - * children: React.ReactNode - * }} props - */ -function RouteSurface({ routeId, buildId, children }) { - const { diagnostics } = useApplication(); - return ( - } - > - }> - {children} - - - ); -} - -/** - * @param {{ - * routeId: string, - * children: React.ReactNode - * }} props - */ -function ProtectedRoute({ routeId, children }) { - const location = useLocation(); - const { sessionState, beginSignIn, recover } = useSession(); - const [pending, setPending] = useState(false); - const [failed, setFailed] = useState(false); - const decision = decideRouteAccess(routeId, sessionState); - - async function continueSession() { - setPending(true); - setFailed(false); - try { - if (decision.action === "wait-for-session") { - await recover(); - } else { - await beginSignIn( - `${location.pathname}${location.search}${location.hash}`, - ); - } - } catch { - setFailed(true); - } finally { - setPending(false); - } - } - - if (decision.allowed) return children; - - if (sessionState === "integration-failed") { - return ( -
- -
- ); - } - - const recovering = decision.action === "wait-for-session"; - return ( -
- -
- -
- {failed ? ( -

- 세션 작업을 완료하지 못했습니다. -

- ) : null} -
- ); -} - -/** - * @param {{ - * routeId: string, - * buildId: string, - * children: React.ReactNode - * }} props - */ -function PublicRoute({ routeId, buildId, children }) { - return ( - - {children} - - ); -} - -/** - * @param {{ - * basename?: string, - * buildId?: string - * }} props - */ -export function AppRouter({ - basename = "/", - buildId = "local-build", -}) { - return ( - - - - - }> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - - } - /> - - - - } - /> - - - - - - ); -} diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx new file mode 100644 index 0000000..a528c81 --- /dev/null +++ b/src/presentation/routes/app-router.tsx @@ -0,0 +1,309 @@ +import { + createContext, + type ReactNode, + Suspense, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + createBrowserRouter, + RouterProvider, + type RouteObject, + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; + +import { + getRoute, + ROUTE_REGISTRY, + type RouteDefinition, +} from "../../contracts/routes.js"; +import { + FeatureBoundary, + RouteBoundary, +} from "../boundaries/render-error-boundary.jsx"; +import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js"; +import { PageHeader } from "../components/page-header.jsx"; +import { AppShell } from "../layouts/app-shell.jsx"; +import { useApplication } from "../providers/application-provider.js"; +import { SessionProvider, useSession } from "../providers/session-provider.jsx"; +import { ThemeProvider } from "../providers/theme-provider.jsx"; +import { + createRedirectLoopGuard, + decideRouteAccess, +} from "./navigation-policy.js"; +import { + buildRouteUrl, + parseRouteInput, + type ParsedRouteInput, + type RouteId, +} from "./route-codecs.js"; +import { ROUTE_RUNTIME } from "./route-runtime.js"; + +const RouteInputContext = createContext(null); + +export function useRouteInput(): ParsedRouteInput { + const input = useContext(RouteInputContext); + if (!input) throw new Error("Registered route input is required"); + return input; +} + +function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) { + return ( +
+
+ ); +} + +function RouteFailureSurface({ + definition, +}: { + definition?: RouteDefinition; +}) { + return ( +
+ +
+ ); +} + +function InvalidRouteSurface({ code }: { code: string }) { + return ( +
+ +

안전한 탐색 링크를 사용해 주세요.

+
+ ); +} + +function RouteLifecycle({ definition }: { definition: RouteDefinition }) { + const location = useLocation(); + useEffect(() => { + document.title = `${definition.title} · Frontend Skeleton`; + const main = document.getElementById("main-content"); + main?.focus({ preventScroll: true }); + try { + if (!navigator.userAgent.toLowerCase().includes("jsdom")) { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + } + } catch { + // Non-browser test hosts may not implement scrolling. + } + }, [definition, location.key, location.pathname]); + return null; +} + +function CanonicalRouteRedirect({ + input, +}: { + input: ParsedRouteInput; +}) { + const location = useLocation(); + const navigate = useNavigate(); + const guard = useRef(createRedirectLoopGuard(3)); + useEffect(() => { + if (input.routeId === "NOT_FOUND") return; + const source = `${location.pathname}${location.search}`; + const target = buildRouteUrl(input.routeId, { + params: input.params, + search: input.search, + }); + if (source !== target && guard.current.allow(source, target)) { + void navigate(target, { replace: true }); + } + }, [input, location.pathname, location.search, navigate]); + return null; +} + +function ProtectedRoute({ + routeId, + children, +}: { + routeId: RouteId; + children: ReactNode; +}) { + const location = useLocation(); + const { sessionState, beginSignIn, recover } = useSession(); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + const decision = decideRouteAccess(routeId, sessionState); + + async function continueSession() { + setPending(true); + setFailed(false); + try { + if (decision.action === "wait-for-session") { + await recover(); + } else { + await beginSignIn( + `${location.pathname}${location.search}${location.hash}`, + ); + } + } catch { + setFailed(true); + } finally { + setPending(false); + } + } + + if (decision.allowed) return children; + if (sessionState === "integration-failed") { + return ( +
+ +
+ ); + } + const recovering = decision.action === "wait-for-session"; + return ( +
+ + + {failed ? ( +

+ 세션 작업을 완료하지 못했습니다. +

+ ) : null} +
+ ); +} + +function RegisteredRoute({ + routeId, + buildId, +}: { + routeId: RouteId; + buildId: string; +}) { + const definition = getRoute(routeId); + const runtime = ROUTE_RUNTIME[routeId]; + const params = useParams(); + const [search] = useSearchParams(); + const location = useLocation(); + const { diagnostics, recovery } = useApplication(); + const parsed = parseRouteInput(routeId, params, search); + if (!parsed.success) return ; + + const content = ( + + + + }> + + + + + + ); + const protectedContent = + definition.access === "public" ? ( + content + ) : ( + {content} + ); + const boundaryProps = { + routeId, + buildId, + resetKey: `${location.pathname}${location.search}`, + onRenderFailure: diagnostics.reportRenderFailure, + fallback: , + children: protectedContent, + }; + return definition.errorSurface === "feature-boundary" ? ( + + ) : ( + + ); +} + +function createRegisteredRoutes(buildId: string): RouteObject[] { + const children = Object.values(ROUTE_REGISTRY).map((definition) => { + const routeId = definition.routeId as RouteId; + if (definition.path === "/") { + return { + id: routeId, + index: true, + element: , + }; + } + return { + id: routeId, + path: + definition.path === "*" + ? "*" + : definition.path.replace(/^\//, ""), + element: , + }; + }); + return [ + { + id: "APP_SHELL", + path: "/", + element: , + errorElement: , + children, + }, + ]; +} + +export function AppRouter({ + basename = "/", + buildId = "local-build", +}: Readonly<{ basename?: string; buildId?: string }>) { + const router = useMemo( + () => + createBrowserRouter(createRegisteredRoutes(buildId), { + basename, + }), + [basename, buildId], + ); + return ( + + + + + + ); +} diff --git a/src/presentation/routes/navigation-policy.js b/src/presentation/routes/navigation-policy.js index 0dd3468..5170793 100644 --- a/src/presentation/routes/navigation-policy.js +++ b/src/presentation/routes/navigation-policy.js @@ -16,8 +16,10 @@ export function decideRouteAccess(routeId, sessionState) { return { allowed: false, action: "show-sign-in" }; } -export function createRedirectLoopGuard() { +/** @param {number} [maxHops] */ +export function createRedirectLoopGuard(maxHops = 5) { const visitedPairs = new Set(); + let hops = 0; return Object.freeze({ /** @@ -26,12 +28,23 @@ export function createRedirectLoopGuard() { */ allow(source, target) { const pair = `${source}->${target}`; - if (source === target || visitedPairs.has(pair)) return false; + if ( + source === target || + visitedPairs.has(pair) || + hops >= maxHops + ) { + return false; + } visitedPairs.add(pair); + hops += 1; return true; }, reset() { visitedPairs.clear(); + hops = 0; + }, + get hopCount() { + return hops; }, }); } diff --git a/src/presentation/routes/route-codecs.ts b/src/presentation/routes/route-codecs.ts new file mode 100644 index 0000000..9376165 --- /dev/null +++ b/src/presentation/routes/route-codecs.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import { getRoute } from "../../contracts/routes.js"; +import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.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>; + search: Readonly>; +}>; + +export type RouteInputResult = + | Readonly<{ success: true; data: ParsedRouteInput }> + | Readonly<{ + success: false; + code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; + }>; + +export function parseRouteInput( + routeId: RouteId, + rawParams: Readonly>, + rawSearch: URLSearchParams, +): RouteInputResult { + const runtime = ROUTE_RUNTIME_CONTRACT[routeId]; + const params = codecs[runtime.paramsCodec].safeParse(rawParams); + if (!params.success) { + return { success: false, code: "ROUTE_PARAMS_INVALID" }; + } + const search = codecs[runtime.searchCodec].safeParse( + searchRecord(rawSearch), + ); + if (!search.success) { + return { success: false, code: "ROUTE_SEARCH_INVALID" }; + } + const parsedParams: Record = { ...params.data }; + const parsedSearch: Record = { ...search.data }; + return { + success: true, + data: Object.freeze({ + routeId, + params: Object.freeze(parsedParams), + search: Object.freeze(parsedSearch), + }), + }; +} + +export function buildRouteUrl( + routeId: RouteId, + input: Readonly<{ + params?: Readonly>; + search?: Readonly>; + }> = {}, +): string { + const definition = getRoute(routeId); + if (definition.path === "*") { + 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 parsedParams: Record = { ...params }; + const parsedSearch: Record = { ...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) => { + const name = colonName ?? braceName ?? ""; + const value = parsedParams[name]; + if (typeof value !== "string" && typeof value !== "number") { + throw new TypeError(`Missing route path parameter: ${name}`); + } + return encodeURIComponent(String(value)); + }, + ); + const query = new URLSearchParams(); + for (const key of Object.keys(parsedSearch).sort((left, right) => + left.localeCompare(right), + )) { + const value = parsedSearch[key]; + if (value === undefined || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) { + query.append(key, String(item)); + } + } + const serialized = query.toString(); + return serialized ? `${path}?${serialized}` : path; +} + +function searchRecord( + search: URLSearchParams, +): Readonly> { + const result: Record = {}; + for (const key of [...new Set(search.keys())].sort((left, right) => + left.localeCompare(right), + )) { + const values = search.getAll(key); + result[key] = values.length === 1 ? values[0] : values; + } + return result; +} diff --git a/src/presentation/routes/route-runtime.tsx b/src/presentation/routes/route-runtime.tsx new file mode 100644 index 0000000..7955125 --- /dev/null +++ b/src/presentation/routes/route-runtime.tsx @@ -0,0 +1,48 @@ +import { + lazy, + type ComponentType, + type LazyExoticComponent, +} from "react"; + +import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js"; +import type { RouteId } from "./route-codecs.js"; + +type RouteModule = Readonly<{ default: ComponentType }>; +type RouteRuntime = Readonly<{ + moduleId: string; + Component: LazyExoticComponent; +}>; + +function runtime( + routeId: RouteId, + load: () => Promise, +): RouteRuntime { + return Object.freeze({ + moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId, + Component: lazy(load), + }); +} + +export const ROUTE_RUNTIME = { + APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")), + EXAMPLES_UI: runtime( + "EXAMPLES_UI", + () => import("../examples/ui-gallery-page.jsx"), + ), + EXAMPLES_STATES: runtime( + "EXAMPLES_STATES", + () => import("../examples/state-gallery-page.jsx"), + ), + EXAMPLES_AUTH: 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; diff --git a/tests/component/chunk-recovery-boundary.test.tsx b/tests/component/chunk-recovery-boundary.test.tsx new file mode 100644 index 0000000..1fe7e2c --- /dev/null +++ b/tests/component/chunk-recovery-boundary.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + ChunkRecoveryBoundary, + isChunkLoadFailure, +} from "../../src/presentation/boundaries/chunk-recovery-boundary.js"; +import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx"; + +function ChunkDefect(): never { + throw new TypeError("Failed to fetch dynamically imported module"); +} + +function RenderDefect(): never { + throw new Error("ordinary render defect"); +} + +describe("chunk recovery boundary classification", () => { + it("recognizes lazy module failures without classifying ordinary render errors", () => { + expect( + isChunkLoadFailure( + new TypeError("Failed to fetch dynamically imported module"), + ), + ).toBe(true); + expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false); + }); + + it("runs the recovery input only for a lazy chunk rejection", async () => { + const recover = vi.fn(async () => ({ + action: "support" as const, + reason: "reload-already-attempted", + })); + render( + + + , + ); + + expect( + await screen.findByRole("heading", { + name: "화면 자산을 복구하지 못했습니다.", + }), + ).toBeVisible(); + expect(recover).toHaveBeenCalledOnce(); + expect(recover).toHaveBeenCalledWith({ + chunkId: "route-home", + failureKind: "CHUNK_LOAD_FAILURE", + }); + }); + + it("rethrows an ordinary component defect to the local render boundary", () => { + const recover = vi.fn(); + render( + + + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure"); + expect(recover).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/component/render-boundary.test.jsx b/tests/component/render-boundary.test.jsx index f605677..8f99b71 100644 --- a/tests/component/render-boundary.test.jsx +++ b/tests/component/render-boundary.test.jsx @@ -91,4 +91,34 @@ describe("render recovery boundaries", () => { ); expect(screen.getByText("recovered")).toBeVisible(); }); + + it("resets a route failure when the registered location key changes", async () => { + let shouldThrow = true; + function RouteContent() { + if (shouldThrow) throw new Error("route defect"); + return

next route

; + } + const view = render( + + + , + ); + expect(screen.getByRole("alert")).toBeVisible(); + + shouldThrow = false; + view.rerender( + + + , + ); + expect(await screen.findByText("next route")).toBeVisible(); + }); }); diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx index 69c06f0..29d1b80 100644 --- a/tests/component/router.test.jsx +++ b/tests/component/router.test.jsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createAnonymousSessionAdapter, @@ -14,10 +14,13 @@ import { createTestApplication } from "../helpers/create-test-application.js"; /** * @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session + * @param {Parameters[0]} [overrides] */ -function renderRouter(session) { +function renderRouter(session, overrides = {}) { return render( - + , ); @@ -50,6 +53,10 @@ describe("application router", () => { await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }), ).toBeVisible(); expect(window.location.pathname).toBe("/examples/ui"); + expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"); + expect( + screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), + ).toHaveFocus(); }); it("reacts to demo sign-in and opens the protected integration route", async () => { @@ -82,4 +89,31 @@ describe("application router", () => { 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(); + }); }); diff --git a/tests/component/runtime-application.test.jsx b/tests/component/runtime-application.test.jsx index 0a2c94e..b3bdb44 100644 --- a/tests/component/runtime-application.test.jsx +++ b/tests/component/runtime-application.test.jsx @@ -30,6 +30,14 @@ const releaseManifest = { 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-sample-resources": "assets/sample.js", + "route-not-found": "assets/not-found.js", + }, }; describe("production runtime application tree", () => { diff --git a/tests/fixtures/registry/routes/governance.json b/tests/fixtures/registry/routes/governance.json new file mode 100644 index 0000000..24cfa0d --- /dev/null +++ b/tests/fixtures/registry/routes/governance.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "sourceDirectories": [], + "registries": [ + { + "registryId": "ROUTES", + "path": "tests/fixtures/registry/routes/invalid-routes.js", + "exportName": "INVALID_ROUTES", + "owner": "fixture", + "requiredFields": [ + "routeId", + "path", + "paramsSchema", + "searchSchema", + "loadingSurface", + "errorSurface", + "chunkId" + ], + "uniqueFields": ["routeId", "path", "chunkId"], + "allowedValues": { + "paramsSchema": [null], + "searchSchema": [null], + "loadingSurface": ["app-shell"], + "errorSurface": ["route-boundary"], + "chunkId": ["route-home"] + }, + "references": [ + { + "field": "routeId", + "registryId": "RUNTIME", + "targetField": "routeId" + } + ] + }, + { + "registryId": "RUNTIME", + "path": "tests/fixtures/registry/routes/invalid-routes.js", + "exportName": "INVALID_RUNTIME", + "owner": "fixture", + "requiredFields": ["routeId", "moduleId", "paramsCodec", "searchCodec"], + "allowedValues": { + "moduleId": ["home-page"], + "paramsCodec": ["none"], + "searchCodec": ["none"] + }, + "references": [ + { + "field": "routeId", + "registryId": "ROUTES", + "targetField": "routeId" + } + ] + } + ], + "compatibilityImpact": { + "allowed": ["none", "additive", "behavior-change", "breaking"], + "current": "breaking" + } +} diff --git a/tests/fixtures/registry/routes/invalid-routes.ts b/tests/fixtures/registry/routes/invalid-routes.ts new file mode 100644 index 0000000..8374122 --- /dev/null +++ b/tests/fixtures/registry/routes/invalid-routes.ts @@ -0,0 +1,29 @@ +export const INVALID_ROUTES = { + FIRST: { + routeId: "DUPLICATE", + path: "/duplicate", + paramsSchema: "UnknownParams", + searchSchema: "UnknownSearch", + loadingSurface: "unknown-loading", + errorSurface: "unknown-error", + chunkId: "unknown-chunk", + }, + SECOND: { + routeId: "DUPLICATE", + path: "/duplicate", + paramsSchema: null, + searchSchema: null, + loadingSurface: "app-shell", + errorSurface: "route-boundary", + chunkId: "route-home", + }, +}; + +export const INVALID_RUNTIME = { + ORPHAN: { + routeId: "ORPHAN", + moduleId: "unknown-module", + paramsCodec: "none", + searchCodec: "none", + }, +}; diff --git a/tests/fixtures/typecheck/invalid-route-runtime.ts b/tests/fixtures/typecheck/invalid-route-runtime.ts new file mode 100644 index 0000000..86df381 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-route-runtime.ts @@ -0,0 +1,14 @@ +import { ROUTE_RUNTIME_CONTRACT } from "../../../src/contracts/route-runtime-contract.js"; + +type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT; + +export const missingRuntime = { + APP_HOME: true, +} satisfies Record; + +export const orphanRuntime = { + ...Object.fromEntries( + Object.keys(ROUTE_RUNTIME_CONTRACT).map((routeId) => [routeId, true]), + ), + ORPHAN_ROUTE: true, +} satisfies Record; diff --git a/tests/helpers/create-test-application.js b/tests/helpers/create-test-application.js index 6a07a14..c54f83b 100644 --- a/tests/helpers/create-test-application.js +++ b/tests/helpers/create-test-application.js @@ -6,7 +6,8 @@ import { createApplication } from "../../src/application/create-application.js"; * session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort, * 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 + * releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort, + * navigation?: { reload(): void } * }} [overrides] */ export function createTestApplication(overrides = {}) { @@ -29,7 +30,23 @@ export function createTestApplication(overrides = {}) { 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: () => {} }, }); } diff --git a/tests/runtime-schema/release-manifest.test.js b/tests/runtime-schema/release-manifest.test.js index 900cdbd..03e5987 100644 --- a/tests/runtime-schema/release-manifest.test.js +++ b/tests/runtime-schema/release-manifest.test.js @@ -25,6 +25,7 @@ const manifest = { assetManifestHash: "hash-a", releaseId: "release-a", builtAt: "2026-07-25T00:00:00Z", + routeChunks: { "route-home": "assets/home.js" }, }; describe("release manifest boot boundary", () => { @@ -46,6 +47,62 @@ describe("release manifest boot boundary", () => { new Response(JSON.stringify({ ...manifest, buildId: "build-b" })), }, ), + ).rejects.toMatchObject({ + kind: "BUILD_MISMATCH", + code: "MANIFEST_BUILD_MISMATCH", + }); + }); + + it.each([ + [ + { configSchemaVersion: "2" }, + {}, + "CONFIG_MISMATCH", + "MANIFEST_CONFIG_SCHEMA_MISMATCH", + ], + [ + { apiContractVersion: "2" }, + {}, + "API_CONTRACT_MISMATCH", + "MANIFEST_API_CONTRACT_MISMATCH", + ], + [ + { releaseId: "release-b" }, + {}, + "RELEASE_MISMATCH", + "MANIFEST_RELEASE_MISMATCH", + ], + [ + {}, + { expectedAssetManifestHash: "different" }, + "ASSET_MISMATCH", + "MANIFEST_ASSET_MISMATCH", + ], + ])( + "classifies tuple mismatch %# without a generic deploy error", + async (manifestOverride, options, kind, code) => { + await expect( + loadReleaseManifest( + /** @type {Parameters[0]} */ (runtime), + { + fetcher: async () => + Response.json({ ...manifest, ...manifestOverride }), + ...options, + }, + ), + ).rejects.toMatchObject({ kind, code }); + }, + ); + + it("rejects a manifest without a complete route chunk map", async () => { + const malformed = Object.fromEntries( + Object.entries(manifest).filter(([key]) => key !== "routeChunks"), + ); + await expect( + loadReleaseManifest( + /** @type {Parameters[0]} */ (runtime), + { fetcher: async () => Response.json(malformed) }, + ), ).rejects.toBeInstanceOf(ReleaseManifestError); }); }); diff --git a/tests/unit/application-boundary.test.ts b/tests/unit/application-boundary.test.ts index bfaf94d..30e5628 100644 --- a/tests/unit/application-boundary.test.ts +++ b/tests/unit/application-boundary.test.ts @@ -15,6 +15,7 @@ describe("application input/output boundary", () => { "preferences", "diagnostics", "runtime", + "recovery", ]); expect(application).not.toHaveProperty("storage"); expect(application).not.toHaveProperty("telemetry"); @@ -51,8 +52,18 @@ describe("application input/output boundary", () => { configSchemaVersion: "1", apiContractVersion: "1", assetManifestHash: "hash-a", + routeChunks: { "route-home": "assets/home.js" }, + }), + refresh: async () => ({ + buildId: "build-a", + releaseId: "release-a", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "hash-a", + routeChunks: { "route-home": "assets/home.js" }, }), }, + navigation: { reload: () => {} }, } satisfies ApplicationOutputPorts; const application = createApplication(ports); diff --git a/tests/unit/chunk-recovery-runtime.test.ts b/tests/unit/chunk-recovery-runtime.test.ts new file mode 100644 index 0000000..dda1a6d --- /dev/null +++ b/tests/unit/chunk-recovery-runtime.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createApplication } from "../../src/application/create-application.js"; +import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; +import type { StoragePort } from "../../src/application/ports/storage-port.js"; + +type ReleaseFixture = { + buildId: string; + releaseId: string; + configSchemaVersion: string; + apiContractVersion: string; + assetManifestHash: string; + routeChunks: Record; +}; + +function release(buildId: string, releaseId: string): ReleaseFixture { + return { + buildId, + releaseId, + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: `${buildId}-assets`, + routeChunks: { "route-home": `assets/${buildId}-home.js` }, + }; +} + +function memoryStorage(): StoragePort { + let value: unknown; + return { + read: () => ({ ok: true, value }), + write: (_name, next) => { + value = next; + return { ok: true }; + }, + remove: () => ({ ok: true }), + }; +} + +function applicationWith(options: { + storage?: StoragePort; + refresh?: () => Promise>; + reload?: () => void; +}) { + const current = release("build-a", "release-a"); + return createApplication({ + session: createAnonymousSessionAdapter(), + preferences: options.storage ?? memoryStorage(), + diagnostics: { emit: () => {} }, + releaseInfo: { + getCurrent: async () => current, + refresh: + options.refresh ?? + (async () => release("build-b", "release-b")), + }, + navigation: { reload: options.reload ?? (() => {}) }, + }); +} + +describe("production chunk recovery application input", () => { + it("reloads exactly once for one active build/release pair", async () => { + const reload = vi.fn(); + const application = applicationWith({ reload }); + const input = { + chunkId: "route-home", + failureKind: "CHUNK_LOAD_FAILURE" as const, + }; + + await expect(application.recovery.recoverChunk(input)).resolves.toEqual({ + action: "reload-once", + releasePair: "build-a/release-a->build-b/release-b", + }); + await expect(application.recovery.recoverChunk(input)).resolves.toEqual({ + action: "support", + reason: "reload-already-attempted", + }); + expect(reload).toHaveBeenCalledOnce(); + }); + + it.each([ + [ + { + refresh: async () => { + throw new Error("offline"); + }, + }, + "manifest-unavailable", + ], + [ + { + refresh: async () => ({ + ...release("build-b", "release-b"), + routeChunks: {}, + }), + }, + "active-chunk-unknown", + ], + [ + { + storage: { + read: () => ({ + ok: false as const, + error: { + kind: "STORAGE_UNAVAILABLE", + code: "STORAGE_UNAVAILABLE", + retryable: false, + operationId: "STORAGE", + attemptCount: 1, + userMessageKey: "error.storage_unavailable", + action: "none" as const, + }, + }), + write: () => ({ ok: true as const }), + remove: () => ({ ok: true as const }), + }, + }, + "guard-read-failed", + ], + ])("fails closed for recovery dependency case %#", async (options, reason) => { + const reload = vi.fn(); + const application = applicationWith({ ...options, reload }); + await expect( + application.recovery.recoverChunk({ + chunkId: "route-home", + failureKind: "CHUNK_LOAD_FAILURE", + }), + ).resolves.toEqual({ action: "support", reason }); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/chunk-recovery.test.js b/tests/unit/chunk-recovery.test.js index 66b6c0b..703a81d 100644 --- a/tests/unit/chunk-recovery.test.js +++ b/tests/unit/chunk-recovery.test.js @@ -22,12 +22,14 @@ describe("controlled chunk recovery", () => { failureKind: "CHUNK_LOAD_FAILURE", manifestLoaded: true, currentBuildId: "build-a", + currentReleaseId: "release-a", + activeBuildId: "build-b", activeReleaseId: "release-b", storage, }; expect(decideChunkRecovery(input)).toEqual({ action: "reload-once", - releasePair: "build-a->release-b", + releasePair: "build-a/release-a->build-b/release-b", }); expect(decideChunkRecovery(input)).toEqual({ action: "support", @@ -38,12 +40,17 @@ describe("controlled chunk recovery", () => { it.each([ [{ failureKind: "SERVER_FAILURE" }, "not-recoverable"], [{ manifestLoaded: false }, "manifest-unavailable"], - [{ activeReleaseId: "build-a" }, "same-release"], + [ + { activeBuildId: "build-a", activeReleaseId: "release-a" }, + "same-release", + ], ])("stops when a recovery invariant fails: %#", (override, reason) => { const result = decideChunkRecovery({ failureKind: "DEPLOY_MISMATCH", manifestLoaded: true, currentBuildId: "build-a", + currentReleaseId: "release-a", + activeBuildId: "build-b", activeReleaseId: "release-b", storage: memoryStorage(), ...override, diff --git a/tests/unit/error-classification.test.js b/tests/unit/error-classification.test.js index 600ba64..2a44a8b 100644 --- a/tests/unit/error-classification.test.js +++ b/tests/unit/error-classification.test.js @@ -9,7 +9,7 @@ import { describe("frontend failure classification", () => { it("defines all 26 stable error kinds with the seven contract fields", () => { - expect(Object.keys(ERROR_REGISTRY)).toHaveLength(26); + expect(Object.keys(ERROR_REGISTRY)).toHaveLength(31); for (const definition of Object.values(ERROR_REGISTRY)) { expect(definition).toEqual( expect.objectContaining({ diff --git a/tests/unit/navigation-policy.test.js b/tests/unit/navigation-policy.test.js index 4ea27a3..d235481 100644 --- a/tests/unit/navigation-policy.test.js +++ b/tests/unit/navigation-policy.test.js @@ -72,7 +72,7 @@ describe("route registry", () => { "loadingSurface": "none", "navigationLabel": null, "navigationOrder": null, - "paramsSchema": null, + "paramsSchema": "NotFoundSplat", "path": "*", "routeId": "NOT_FOUND", "searchSchema": null, @@ -121,9 +121,12 @@ describe("route registry", () => { }); it("allows at most one automatic redirect per source-target pair", () => { - const guard = createRedirectLoopGuard(); + const guard = createRedirectLoopGuard(2); expect(guard.allow("/private", "/signin")).toBe(true); expect(guard.allow("/private", "/signin")).toBe(false); expect(guard.allow("/signin", "/signin")).toBe(false); + expect(guard.allow("/signin", "/continue")).toBe(true); + expect(guard.allow("/continue", "/final")).toBe(false); + expect(guard.hopCount).toBe(2); }); }); diff --git a/tests/unit/registry-governance.test.js b/tests/unit/registry-governance.test.js index fa9a553..5f7a8d3 100644 --- a/tests/unit/registry-governance.test.js +++ b/tests/unit/registry-governance.test.js @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; describe("registry governance manifest", () => { - it("declares exactly eight single-owner registries and impact labels", async () => { + it("declares exactly nine single-owner registries and impact labels", async () => { const governance = JSON.parse( await readFile("config/contracts/registry-governance.json", "utf8"), ); @@ -10,9 +10,9 @@ describe("registry governance manifest", () => { /** @type {Array<{registryId: string, owner: string}>} */ ( governance.registries ); - expect(governance.registries).toHaveLength(8); + expect(governance.registries).toHaveLength(9); expect(new Set(registries.map((entry) => entry.registryId)).size).toBe( - 8, + 9, ); expect(registries.every((entry) => entry.owner)).toBe(true); expect(governance.compatibilityImpact.allowed).toEqual([ diff --git a/tests/unit/route-runtime.test.ts b/tests/unit/route-runtime.test.ts new file mode 100644 index 0000000..b1285c5 --- /dev/null +++ b/tests/unit/route-runtime.test.ts @@ -0,0 +1,69 @@ +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"; + +describe("typed route contract and runtime", () => { + it("keeps contract, runtime contribution, and executable module complete", () => { + expect(Object.keys(ROUTE_RUNTIME_CONTRACT).sort()).toEqual( + Object.keys(ROUTE_REGISTRY).sort(), + ); + expect(Object.keys(ROUTE_RUNTIME).sort()).toEqual( + Object.keys(ROUTE_REGISTRY).sort(), + ); + }); + + 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" }); + expect( + parseRouteInput( + "NOT_FOUND", + { "*": "missing/path" }, + new URLSearchParams(), + ), + ).toMatchObject({ success: true }); + }); +}); diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index 3e4f39a..d57e3a1 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -13,6 +13,7 @@ const runtime = { AUTH_MODE: "demo", REQUEST_TIMEOUT_MS: 4321, MAX_RETRY_ATTEMPTS: 0, + RELEASE_MANIFEST_URL: "/release-manifest.json", }, }; const release = /** @type {const} */ ({ @@ -25,6 +26,7 @@ const release = /** @type {const} */ ({ assetManifestHash: "hash-a", releaseId: "release-a", builtAt: "2026-07-25T00:00:00Z", + routeChunks: { "route-home": "assets/home.js" }, }); describe("runtime adapter composition", () => { @@ -59,6 +61,34 @@ describe("runtime adapter composition", () => { expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); }); + it("refetches the active release manifest with no-store semantics", async () => { + const activeRelease = { + ...release, + buildId: "build-b", + releaseId: "release-b", + routeChunks: { "route-home": "assets/home-b.js" }, + }; + const fetcher = vi.fn(async () => Response.json(activeRelease)); + const adapters = await createRuntimeAdapters({ + runtime: + /** @type {Parameters[0]["runtime"]} */ ( + runtime + ), + release, + fetcher, + host: {}, + }); + + await expect(adapters.outputPorts.releaseInfo.refresh()).resolves.toMatchObject({ + buildId: "build-b", + releaseId: "release-b", + }); + expect(fetcher).toHaveBeenCalledWith("/release-manifest.json", { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + }); + it("injects runtime timeout and max-attempt policy into HTTP execution", async () => { const scheduled = /** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);