Compare commits

..
35 changed files with 1745 additions and 51 deletions
+2
View File
@@ -1,6 +1,7 @@
node_modules/
dist/
.vite/
.tmp/
playwright-report/
test-results/
coverage/
@@ -8,4 +9,5 @@ artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
artifacts/tests/e2e/
!artifacts/**/.gitkeep
+18
View File
@@ -0,0 +1,18 @@
# APP_HOME accessibility review
Status: pending-manual-review
Route ID: APP_HOME
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Automated axe, keyboard-focus, and reduced-motion evidence is available; human review pending.
+18
View File
@@ -0,0 +1,18 @@
# NOT_FOUND accessibility review
Status: pending-manual-review
Route ID: NOT_FOUND
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# SAMPLE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: SAMPLE_RESOURCE_LIST
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+47
View File
@@ -0,0 +1,47 @@
# Manual accessibility review checklist
Automated axe checks do not establish WCAG conformance. A human reviewer must
review all three route records in `artifacts/tests/a11y-manual/` against one
release candidate and sign them. Copy the template fields exactly; the gate
rejects blank identity/timestamp/signature fields, pending verdicts, mismatched
release IDs, or missing routes.
Allowed item verdicts:
- `pass`
- `not-applicable (<specific reason>)`
Required record:
```text
Status: reviewed
Route ID: APP_HOME
Release ID: <immutable release ID>
Reviewer: <human reviewer identity>
Reviewed at: <RFC 3339 timestamp>
Signature: <reviewer identity or approved signature reference>
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: <observations and linked defect IDs>
```
The reviewer must verify:
- M1: every action works without a pointing device
- M2: every focused element has a visible indicator
- M3: route transitions move focus to a deterministic target
- M4: modal focus is trapped and restored, when a modal exists
- M5: errors are programmatically associated with their controls, when present
- M6: state never relies on color alone
- M7: non-essential motion is suppressed with reduced-motion preference
- Screen reader: headings, live regions, errors, and actions are announced once
Passing automated evidence means only that tested pages had no critical or
serious axe findings under the recorded browser run.
+17
View File
@@ -0,0 +1,17 @@
# Design-token styling contract
`src/presentation/styles/theme.css` is the styling SSOT. Components consume
semantic color, spacing, typography, and radius tokens through static Tailwind
classes.
Arbitrary-value policy:
- prefer a named semantic token
- bracket values are allowed only for one-off platform constraints that cannot
be expressed by the current scale
- a repeated bracket value must be promoted into `@theme`
- 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 product modules must not
import from `src/sample/contract-fixture`.
+6 -1
View File
@@ -21,13 +21,16 @@
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y",
"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": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.1",
"zod": "4.4.3"
},
"devDependencies": {
@@ -37,6 +40,7 @@
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
@@ -46,6 +50,7 @@
"globals": "17.7.0",
"jsdom": "29.1.1",
"msw": "2.15.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
screenshot: "only-on-failure",
},
webServer: {
command: "pnpm dev --host 127.0.0.1",
command: "corepack pnpm dev --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
+393 -19
View File
@@ -17,6 +17,9 @@ importers:
react-dom:
specifier: 19.2.8
version: 19.2.8(react@19.2.8)
react-router-dom:
specifier: 7.18.1
version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
zod:
specifier: 4.4.3
version: 4.4.3
@@ -26,10 +29,13 @@ importers:
version: 4.12.1(playwright-core@1.62.0)
'@eslint/js':
specifier: 10.0.1
version: 10.0.1(eslint@10.8.0(supports-color@7.2.0))
version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
'@playwright/test':
specifier: 1.62.0
version: 1.62.0
'@tailwindcss/vite':
specifier: 4.3.3
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
'@testing-library/jest-dom':
specifier: 7.0.0
version: 7.0.0(@testing-library/dom@10.4.1)
@@ -50,13 +56,13 @@ importers:
version: 19.2.3(@types/react@19.2.8)
'@vitejs/plugin-react':
specifier: 6.0.4
version: 6.0.4(vite@8.1.5(@types/node@24.13.3))
version: 6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
dependency-cruiser:
specifier: 18.1.0
version: 18.1.0
eslint:
specifier: 10.8.0
version: 10.8.0(supports-color@7.2.0)
version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
globals:
specifier: 17.7.0
version: 17.7.0
@@ -66,15 +72,18 @@ importers:
msw:
specifier: 2.15.0
version: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
tailwindcss:
specifier: 4.3.3
version: 4.3.3
typescript:
specifier: 7.0.2
version: 7.0.2
vite:
specifier: 8.1.5
version: 8.1.5(@types/node@24.13.3)
version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
vitest:
specifier: 4.1.10
version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3))
version: 4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
packages:
@@ -265,9 +274,22 @@ packages:
'@types/node':
optional: true
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mswjs/interceptors@0.41.9':
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
engines: {node: '>=18'}
@@ -399,6 +421,100 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/oxide-android-arm64@4.3.3':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.3.3':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.3.3':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.3.3':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.3.3':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.3.3':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/query-core@5.101.4':
resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==}
@@ -1011,6 +1127,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1048,36 +1168,73 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
@@ -1085,6 +1242,13 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
@@ -1092,6 +1256,13 @@ packages:
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
@@ -1099,6 +1270,13 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
@@ -1106,18 +1284,34 @@ packages:
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
@@ -1260,6 +1454,23 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-router-dom@7.18.1:
resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.18.1:
resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.8:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
@@ -1312,6 +1523,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -1381,6 +1595,9 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
@@ -1684,9 +1901,9 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(supports-color@7.2.0))':
'@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
eslint: 10.8.0(supports-color@7.2.0)
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -1707,9 +1924,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.8.0(supports-color@7.2.0))':
'@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))':
optionalDependencies:
eslint: 10.8.0(supports-color@7.2.0)
eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0)
'@eslint/object-schema@3.0.5': {}
@@ -1763,8 +1980,25 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.3
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mswjs/interceptors@0.41.9':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -1851,6 +2085,74 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.2
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.3
'@tailwindcss/oxide-android-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-x64@4.3.3':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide@4.3.3':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
'@tanstack/query-core@5.101.4': {}
'@tanstack/react-query@5.101.4(react@19.2.8)':
@@ -1991,10 +2293,10 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3))':
'@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@24.13.3)
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
'@vitest/expect@4.1.10':
dependencies:
@@ -2005,14 +2307,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3))':
'@vitest/mocker@4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.15.0(@types/node@24.13.3)(typescript@7.0.2)
vite: 8.1.5(@types/node@24.13.3)
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -2205,9 +2507,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.8.0(supports-color@7.2.0):
eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(supports-color@7.2.0))
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5(supports-color@7.2.0)
'@eslint/config-helpers': 0.7.0
@@ -2237,6 +2539,8 @@ snapshots:
minimatch: 10.2.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.7.0
transitivePeerDependencies:
- supports-color
@@ -2378,6 +2682,8 @@ snapshots:
isexe@2.0.0: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {}
jsdom@29.1.1:
@@ -2425,39 +2731,88 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
@@ -2604,6 +2959,20 @@ snapshots:
react-is@17.0.2: {}
react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
cookie: 1.1.1
react: 19.2.8
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.8(react@19.2.8)
react@19.2.8: {}
rechoir@0.8.0:
@@ -2663,6 +3032,8 @@ snapshots:
semver@7.8.5: {}
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.2: {}
shebang-command@2.0.0:
@@ -2713,6 +3084,8 @@ snapshots:
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
tinybench@2.9.0: {}
@@ -2797,7 +3170,7 @@ snapshots:
dependencies:
punycode: 2.3.1
vite@8.1.5(@types/node@24.13.3):
vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -2807,11 +3180,12 @@ snapshots:
optionalDependencies:
'@types/node': 24.13.3
fsevents: 2.3.3
jiti: 2.7.0
vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)):
vitest@4.1.10(@types/node@24.13.3)(jsdom@29.1.1)(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3))
'@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -2828,7 +3202,7 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.5(@types/node@24.13.3)
vite: 8.1.5(@types/node@24.13.3)(jiti@2.7.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
+58
View File
@@ -0,0 +1,58 @@
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze([
"APP_HOME",
"SAMPLE_RESOURCE_LIST",
"NOT_FOUND",
]);
const REVIEW_FIELDS = Object.freeze([
"M1 Keyboard",
"M2 Visible focus",
"M3 Route focus",
"M4 Modal focus",
"M5 Error association",
"M6 Color signal",
"M7 Reduced motion",
"Screen reader",
]);
/** @param {string} content */
export function validateManualA11yEvidence(content) {
const fields = Object.fromEntries(
content
.split(/\r?\n/)
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
.filter(Boolean)
.map((match) => [
/** @type {RegExpExecArray} */ (match)[1].trim(),
/** @type {RegExpExecArray} */ (match)[2].trim(),
]),
);
const failures = [];
if (fields.Status !== "reviewed") failures.push("Status");
if (!fields["Route ID"]) failures.push("Route ID");
if (!fields["Release ID"]) failures.push("Release ID");
if (!fields.Reviewer) failures.push("Reviewer");
if (!fields.Signature) failures.push("Signature");
if (fields.Attestation !== "accepted") failures.push("Attestation");
if (
!fields["Reviewed at"] ||
!Number.isFinite(Date.parse(fields["Reviewed at"]))
) {
failures.push("Reviewed at");
}
for (const field of REVIEW_FIELDS) {
const result = fields[field];
if (
result !== "pass" &&
!/^not-applicable \(.+\)$/.test(result ?? "")
) {
failures.push(field);
}
}
return Object.freeze({
fields: Object.freeze(fields),
failures: Object.freeze(failures),
passed: failures.length === 0,
});
}
+78
View File
@@ -0,0 +1,78 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
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 pnpmCli = /** @type {string} */ (process.env.npm_execpath);
/** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target];
}),
));
return nested.flat();
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
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));
}
}
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;
}
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>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
process.stderr.write(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Sample removal smoke: PASS\n");
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
MANUAL_A11Y_ROUTE_IDS,
validateManualA11yEvidence,
} from "./lib/manual-a11y-evidence.mjs";
/** @type {Array<{
* routeId: string;
* path: string;
* reviewer: string | null;
* reviewedAt: string | null;
* releaseId: string | null;
* failures: readonly string[];
* passed: boolean;
* }>} */
const results = [];
for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
const path = `artifacts/tests/a11y-manual/${routeId}.md`;
const evidence = await readFile(path, "utf8");
const validation = validateManualA11yEvidence(evidence);
const failures =
validation.fields["Route ID"] === routeId
? validation.failures
: Object.freeze([...validation.failures, "Route ID mismatch"]);
results.push({
routeId,
path,
reviewer: validation.fields.Reviewer ?? null,
reviewedAt: validation.fields["Reviewed at"] ?? null,
releaseId: validation.fields["Release ID"] ?? null,
failures,
passed: validation.passed && failures.length === 0,
});
}
const releaseIds = new Set(results.map((result) => result.releaseId));
const passed =
results.every((result) => result.passed) &&
releaseIds.size === 1 &&
results.every((result) => Boolean(result.releaseId));
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
await writeFile(
"artifacts/tests/a11y-manual/report.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: MANUAL_A11Y_ROUTE_IDS,
results,
coherentRelease: releaseIds.size === 1,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
const failures = results
.filter((result) => !result.passed)
.map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
if (releaseIds.size !== 1) failures.push("release IDs do not match");
process.stderr.write(
`Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Manual accessibility evidence: PASS (${results.length} routes)\n`,
);
+18
View File
@@ -0,0 +1,18 @@
import { mkdir, writeFile } from "node:fs/promises";
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/a11y.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"],
threshold: { critical: 0, serious: 0 },
automatedStatus: "passed",
manualReview: "see artifacts/tests/a11y-manual/report.json",
},
null,
2,
)}\n`,
);
@@ -0,0 +1,34 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @param {{
* failureKind: string,
* manifestLoaded: boolean,
* currentBuildId: string,
* activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort
* }} input
*/
export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) {
return { action: "support", reason: "not-recoverable" };
}
if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" };
}
if (input.activeReleaseId === input.currentBuildId) {
return { action: "support", reason: "same-release" };
}
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" };
}
const recorded = input.storage.write("CHUNK_RELOAD_GUARD", releasePair);
if (!recorded.ok) {
return { action: "support", reason: "guard-write-failed" };
}
return { action: "reload-once", releasePair };
}
+12 -25
View File
@@ -1,27 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
/** @param {{ environment: string }} props */
function BootstrapShell({ environment }) {
return (
<main>
<h1>Clean Architecture Frontend</h1>
<p>{environment} 런타임 계약이 검증되었습니다.</p>
</main>
);
}
/** @param {{ supportReference: string }} props */
function BootErrorShell({ supportReference }) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<p>지원 참조: {supportReference}</p>
</main>
);
}
import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root");
@@ -36,16 +20,19 @@ async function boot() {
const runtime = await loadRuntimeConfig();
root.render(
<StrictMode>
<BootstrapShell environment={runtime.config.APP_ENV} />
<AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
/>
</StrictMode>,
);
} catch (error) {
const supportReference =
const safe =
error instanceof BootConfigError
? error.safe.supportReference
: "boot:unknown";
? error.safe
: { supportReference: "boot:unknown" };
root.render(<BootErrorShell supportReference={supportReference} />);
root.render(<BootErrorShell {...safe} />);
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* @typedef {{
* routeId: string,
* path: string,
* paramsSchema: string | null,
* searchSchema: string | null,
* access: "public" | "session-required" | "integration-defined",
* loadingSurface: string,
* errorSurface: string,
* chunkId: string
* }} RouteDefinition
*/
/** @param {RouteDefinition} definition */
const route = (definition) => Object.freeze(definition);
export const ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({
routeId: "APP_HOME",
path: "/",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
}),
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",
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
path: "*",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "none",
errorSurface: "not-found",
chunkId: "route-not-found",
}),
});
/** @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;
}
@@ -0,0 +1,49 @@
/**
* @param {{
* kind?: string,
* code?: string,
* buildId?: string,
* configSchemaVersion?: string,
* releaseId?: string,
* supportReference: string
* }} props
*/
export function BootErrorShell({
kind = "BOOT_CONFIG_FAILURE",
code = "BOOT_FAILED",
buildId,
configSchemaVersion,
releaseId,
supportReference,
}) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<dl>
<dt>오류</dt>
<dd>{kind}</dd>
<dt>코드</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>빌드</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>설정 스키마</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>릴리스</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>지원 참조: {supportReference}</p>
</main>
);
}
@@ -0,0 +1,68 @@
import { Component } from "react";
/**
* @typedef {{
* children: React.ReactNode,
* boundaryName: string,
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* fallback?: React.ReactNode
* }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState
*/
/** @extends {Component<RenderBoundaryProps, RenderBoundaryState>} */
export class RenderErrorBoundary extends Component {
/** @param {RenderBoundaryProps} props */
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
try {
this.props.telemetry?.emit("ui.render.failed", {
route_id: this.props.routeId,
build_id: this.props.buildId,
component_boundary: this.props.boundaryName,
});
} catch {
// Telemetry must never recurse into another render failure.
}
}
reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>error.render_failure</p>
<button type="button" onClick={this.reset}>
retry
</button>
</section>
)
);
}
return this.props.children;
}
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function RouteBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="route" />;
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function FeatureBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="feature" />;
}
+13 -4
View File
@@ -1,7 +1,12 @@
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
return (
<section aria-busy="true" aria-label={label}>
<section
aria-busy="true"
aria-label={label}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span>
</section>
@@ -11,7 +16,7 @@ export function LoadingSurface({ label = "불러오는 중" }) {
/** @param {{ title?: string, action?: React.ReactNode }} props */
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
return (
<section>
<section className="ui-empty" aria-live="polite">
<p>{title}</p>
{action}
</section>
@@ -28,10 +33,14 @@ export function EmptySurface({ title = "표시할 항목이 없습니다.", acti
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
return (
<section role="alert" aria-labelledby="terminal-error-message">
<section
className="ui-terminal-error"
role="alert"
aria-labelledby="terminal-error-message"
>
<p id="terminal-error-message">{userMessageKey}</p>
{action !== "none" && (
<button type="button" onClick={onAction}>
<button className="ui-button" type="button" onClick={onAction}>
{action}
</button>
)}
+81
View File
@@ -0,0 +1,81 @@
import {
BrowserRouter,
Link,
Route,
Routes,
} from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { decideRouteAccess } from "./navigation-policy.js";
function HomePage() {
return (
<main className="ui-page">
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약이 검증되었습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
</main>
);
}
function SamplePlaceholder() {
return (
<main className="ui-page">
<h1>샘플 리소스</h1>
<p>계약 fixture를 준비하고 있습니다.</p>
</main>
);
}
function NotFoundPage() {
return (
<main className="ui-page">
<h1>페이지를 찾을 없습니다.</h1>
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
</main>
);
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort
* }} props
*/
function GuardedSampleRoute({ authSession }) {
const decision = decideRouteAccess(
"SAMPLE_RESOURCE_LIST",
authSession.getState(),
);
if (!decision.allowed) {
return (
<main className="ui-page">
<h1>세션이 필요합니다.</h1>
<button className="ui-button" type="button">
로그인
</button>
</main>
);
}
return <SamplePlaceholder />;
}
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string
* }} props
*/
export function AppRouter({ authSession, basename = "/" }) {
return (
<BrowserRouter basename={basename}>
<Routes>
<Route path={routePath("APP_HOME")} element={<HomePage />} />
<Route
path={routePath("SAMPLE_RESOURCE_LIST")}
element={<GuardedSampleRoute authSession={authSession} />}
/>
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
@@ -0,0 +1,37 @@
import { getRoute } from "../../contracts/routes.js";
/**
* @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId);
if (route.access === "public") return { allowed: true, action: "none" };
if (sessionState === "authenticated") {
return { allowed: true, action: "none" };
}
if (sessionState === "recovery-pending") {
return { allowed: false, action: "wait-for-session" };
}
return { allowed: false, action: "show-sign-in" };
}
export function createRedirectLoopGuard() {
const visitedPairs = new Set();
return Object.freeze({
/**
* @param {string} source
* @param {string} target
*/
allow(source, target) {
const pair = `${source}->${target}`;
if (source === target || visitedPairs.has(pair)) return false;
visitedPairs.add(pair);
return true;
},
reset() {
visitedPairs.clear();
},
});
}
+71
View File
@@ -0,0 +1,71 @@
@import "tailwindcss";
@theme {
--color-surface: oklch(0.985 0.003 247);
--color-surface-muted: oklch(0.94 0.01 247);
--color-content: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25);
--color-focus: oklch(0.72 0.16 225);
--radius-control: 0.5rem;
--radius-surface: 0.75rem;
--spacing-page: 1.5rem;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
@layer base {
:root {
color: var(--color-content);
background: var(--color-surface);
font-family: var(--font-sans);
}
body {
margin: 0;
}
:focus-visible {
outline: 0.1875rem solid var(--color-focus);
outline-offset: 0.1875rem;
}
}
@layer components {
.ui-page {
@apply mx-auto flex min-h-screen max-w-4xl flex-col gap-6 p-page;
}
.ui-panel {
@apply rounded-surface border border-surface-muted bg-white p-6 shadow-sm;
}
.ui-button {
@apply rounded-control bg-action px-4 py-2 font-semibold text-white;
}
.ui-button:hover {
@apply bg-action-hover;
}
.ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted;
}
.ui-empty,
.ui-terminal-error {
@apply rounded-surface border border-surface-muted p-6;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,15 @@
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>
);
}
@@ -0,0 +1,53 @@
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
),
),
};
},
});
}
@@ -0,0 +1,61 @@
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>
);
}
+14
View File
@@ -0,0 +1,14 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DesignTokenShowcase } from "../../src/sample/contract-fixture/design-token-showcase.jsx";
describe("design-token fixture", () => {
it("uses static semantic primitive classes", () => {
render(<DesignTokenShowcase />);
expect(screen.getByRole("region")).toHaveClass("ui-panel");
expect(screen.getByRole("button")).toHaveClass("ui-button");
});
});
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.js";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function Defect() {
throw new Error("raw render stack");
}
describe("render recovery boundaries", () => {
it("catches programmer defects and emits best-effort safe telemetry", () => {
const telemetry = { emit: vi.fn() };
render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
telemetry={telemetry}
>
<Defect />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", {
route_id: "APP_HOME",
build_id: "build-a",
component_boundary: "feature",
});
});
it("keeps normalized operational failures in normal async state", () => {
const state = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<AsyncSurface state={state} />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure");
});
it("renders a safe boot shell with no endpoint or stack", () => {
render(
<BootErrorShell
kind="BOOT_CONFIG_FAILURE"
code="CONFIG_SCHEMA_INVALID"
buildId="build-a"
configSchemaVersion="1"
supportReference="build-a:CONFIG_SCHEMA_INVALID"
/>,
);
const shell = screen.getByRole("alert");
expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID");
expect(shell).not.toHaveTextContent("https://");
expect(shell).not.toHaveTextContent("stack");
});
it("allows a boundary reset action without reloading the page", async () => {
let shouldThrow = true;
function Recoverable() {
if (shouldThrow) throw new Error("defect");
return <p>recovered</p>;
}
const user = userEvent.setup();
const view = render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
shouldThrow = false;
await user.click(screen.getByRole("button", { name: "retry" }));
view.rerender(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
expect(screen.getByText("recovered")).toBeVisible();
});
});
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
describe("application router", () => {
it("renders not-found without making an API request", () => {
window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
).toBeVisible();
});
it("shows session-required UX without claiming authorization", () => {
window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible();
expect(screen.getByRole("button", { name: "로그인" })).toBeVisible();
});
});
@@ -0,0 +1,49 @@
// @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 () => {
const facade = {
listResources: async () => ({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
}),
createResource: async () => ({ ok: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
expect(await screen.findByText("Example")).toBeVisible();
});
it("renders normalized terminal errors without raw DTO fields", async () => {
const facade = {
listResources: async () => ({
ok: 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: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure");
});
});
+37
View File
@@ -0,0 +1,37 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
for (const route of ["/", "/sample/resources", "/not-found"]) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
await page.goto(route);
await expect(page.getByRole("main")).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations.filter((violation) =>
["critical", "serious"].includes(violation.impact ?? ""),
);
expect(blocking).toEqual([]);
});
}
test("@a11y keyboard reaches the primary route action with visible focus", async ({
page,
}) => {
await page.goto("/");
await page.keyboard.press("Tab");
const action = page.getByRole("link", { name: "샘플 리소스" });
await expect(action).toBeFocused();
await expect(action).toHaveCSS("outline-style", "solid");
});
test("@a11y reduced-motion policy disables long animation", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
const duration = await page
.locator("body")
.evaluate((body) => getComputedStyle(body).animationDuration);
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
});
@@ -0,0 +1,53 @@
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" }],
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js";
function memoryStorage() {
let value;
return {
read: () => ({ ok: true, value }),
write: (_key, next) => {
value = next;
return { ok: true };
},
remove: () => ({ ok: true }),
};
}
describe("controlled chunk recovery", () => {
it("records the release pair before allowing one reload", () => {
const storage = memoryStorage();
const input = {
failureKind: "CHUNK_LOAD_FAILURE",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage,
};
expect(decideChunkRecovery(input)).toEqual({
action: "reload-once",
releasePair: "build-a->release-b",
});
expect(decideChunkRecovery(input)).toEqual({
action: "support",
reason: "reload-already-attempted",
});
});
it.each([
[{ failureKind: "SERVER_FAILURE" }, "not-recoverable"],
[{ manifestLoaded: false }, "manifest-unavailable"],
[{ activeReleaseId: "build-a" }, "same-release"],
])("stops when a recovery invariant fails: %#", (override, reason) => {
const result = decideChunkRecovery({
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage: memoryStorage(),
...override,
});
expect(result).toEqual({ action: "support", reason });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { validateManualA11yEvidence } from "../../scripts/lib/manual-a11y-evidence.mjs";
const reviewed = `Status: reviewed
Route ID: APP_HOME
Release ID: release-1
Reviewer: reviewer@example.test
Reviewed at: 2026-07-25T12:00:00Z
Signature: review-record-1
Attestation: accepted
M1 Keyboard: pass
M2 Visible focus: pass
M3 Route focus: pass
M4 Modal focus: not-applicable (no modal)
M5 Error association: not-applicable (no form error)
M6 Color signal: pass
M7 Reduced motion: pass
Screen reader: pass
Notes: no defects`;
describe("manual accessibility evidence", () => {
it("accepts a complete signed human review record", () => {
expect(validateManualA11yEvidence(reviewed)).toMatchObject({
failures: [],
passed: true,
});
});
it("rejects pending, unsigned, or incomplete evidence", () => {
expect(
validateManualA11yEvidence(
reviewed
.replace("Status: reviewed", "Status: pending-manual-review")
.replace("Signature: review-record-1", "Signature:")
.replace("Screen reader: pass", "Screen reader: pending"),
),
).toMatchObject({
failures: ["Status", "Signature", "Screen reader"],
passed: false,
});
});
it("does not treat an unexplained not-applicable verdict as evidence", () => {
expect(
validateManualA11yEvidence(
reviewed.replace(
"M4 Modal focus: not-applicable (no modal)",
"M4 Modal focus: not-applicable",
),
),
).toMatchObject({
failures: ["M4 Modal focus"],
passed: false,
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "../../src/presentation/routes/navigation-policy.js";
import { 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",
"paramsSchema": null,
"path": "/",
"routeId": "APP_HOME",
"searchSchema": null,
},
"NOT_FOUND": {
"access": "public",
"chunkId": "route-not-found",
"errorSurface": "not-found",
"loadingSurface": "none",
"paramsSchema": null,
"path": "*",
"routeId": "NOT_FOUND",
"searchSchema": null,
},
"SAMPLE_RESOURCE_LIST": {
"access": "integration-defined",
"chunkId": "route-sample-resources",
"errorSurface": "feature-boundary",
"loadingSurface": "sample-resource-list",
"paramsSchema": null,
"path": "/sample/resources",
"routeId": "SAMPLE_RESOURCE_LIST",
"searchSchema": "SampleResourceListQuery",
},
}
`);
});
it("treats client access as a UX hint, not authorization", () => {
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", () => {
const guard = createRedirectLoopGuard();
expect(guard.allow("/private", "/signin")).toBe(true);
expect(guard.allow("/private", "/signin")).toBe(false);
expect(guard.allow("/signin", "/signin")).toBe(false);
});
});
+2 -1
View File
@@ -1,8 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
build: {
manifest: true,
sourcemap: false,