Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b51104010 | ||
|
|
7199d7d9b6 | ||
|
|
f3e105f971 | ||
|
|
1b1fb9bac6 | ||
|
|
946cf407b0 | ||
|
|
0a0b7263b3 | ||
|
|
d7480cbafd | ||
|
|
54d74fcf4a | ||
|
|
49b20bd557 | ||
|
|
24d7072d66 | ||
|
|
7b35f6b551 |
@@ -0,0 +1,49 @@
|
|||||||
|
/** @type {import("dependency-cruiser").IConfiguration} */
|
||||||
|
module.exports = {
|
||||||
|
forbidden: [
|
||||||
|
{
|
||||||
|
name: "domain-is-framework-neutral",
|
||||||
|
severity: "error",
|
||||||
|
from: { path: "^src/domain" },
|
||||||
|
to: {
|
||||||
|
path: "^(src/(application|presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "application-does-not-know-concrete-runtime",
|
||||||
|
severity: "error",
|
||||||
|
from: { path: "^src/application" },
|
||||||
|
to: {
|
||||||
|
path: "^(src/(presentation|adapters|bootstrap)|react|react-dom|@tanstack)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "presentation-does-not-know-adapters",
|
||||||
|
severity: "error",
|
||||||
|
from: { path: "^src/presentation" },
|
||||||
|
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adapters-do-not-know-presentation",
|
||||||
|
severity: "error",
|
||||||
|
from: { path: "^src/adapters" },
|
||||||
|
to: { path: "^src/(presentation|bootstrap)" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-circular-dependencies",
|
||||||
|
severity: "error",
|
||||||
|
from: {},
|
||||||
|
to: { circular: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
doNotFollow: { path: "node_modules" },
|
||||||
|
exclude: {
|
||||||
|
path: "^(dist|artifacts|tests/fixtures)",
|
||||||
|
},
|
||||||
|
enhancedResolveOptions: {
|
||||||
|
exportsFields: ["exports"],
|
||||||
|
conditionNames: ["import", "require", "node", "default"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
coverage/
|
||||||
|
artifacts/**/*.json
|
||||||
|
artifacts/**/*.xml
|
||||||
|
artifacts/**/*.txt
|
||||||
|
artifacts/**/*.sarif
|
||||||
|
!artifacts/**/.gitkeep
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"layers": {
|
||||||
|
"domain": {
|
||||||
|
"root": "src/domain",
|
||||||
|
"mayImport": ["src/domain"]
|
||||||
|
},
|
||||||
|
"application": {
|
||||||
|
"root": "src/application",
|
||||||
|
"mayImport": ["src/application", "src/domain", "src/contracts"]
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"root": "src/presentation",
|
||||||
|
"mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"]
|
||||||
|
},
|
||||||
|
"adapters": {
|
||||||
|
"root": "src/adapters",
|
||||||
|
"mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"]
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"root": "src/bootstrap",
|
||||||
|
"mayImport": ["src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forbidden": [
|
||||||
|
["domain", "application"],
|
||||||
|
["domain", "presentation"],
|
||||||
|
["domain", "adapters"],
|
||||||
|
["domain", "bootstrap"],
|
||||||
|
["application", "presentation"],
|
||||||
|
["application", "adapters"],
|
||||||
|
["application", "bootstrap"],
|
||||||
|
["presentation", "adapters"],
|
||||||
|
["presentation", "bootstrap"],
|
||||||
|
["adapters", "presentation"],
|
||||||
|
["adapters", "bootstrap"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Clean Architecture layer contract
|
||||||
|
|
||||||
|
The import direction is `domain <- application <- presentation`; concrete
|
||||||
|
adapters implement application-owned ports and are assembled only in
|
||||||
|
`src/bootstrap`.
|
||||||
|
|
||||||
|
| Layer | Owns | May depend on |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `domain` | framework-neutral models and pure policies | domain siblings |
|
||||||
|
| `application` | use cases, ports, orchestration, view-models | domain and application siblings |
|
||||||
|
| `presentation` | routes, components, user interaction and view state | application public API and shared UI |
|
||||||
|
| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values |
|
||||||
|
| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules |
|
||||||
|
|
||||||
|
The following edges are forbidden:
|
||||||
|
|
||||||
|
- domain to application, presentation, adapters, bootstrap, React, or browser globals
|
||||||
|
- application to presentation, concrete adapters, bootstrap, React, or browser globals
|
||||||
|
- presentation to concrete adapters, raw DTO schemas, or storage implementations
|
||||||
|
- an adapter to presentation, bootstrap internals, or another concrete adapter
|
||||||
|
|
||||||
|
`bootstrap` contains composition only. Business rules and page-specific
|
||||||
|
orchestration belong to domain/application.
|
||||||
|
|
||||||
|
Architecture reports use this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedAt": "ISO-8601",
|
||||||
|
"rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }],
|
||||||
|
"summary": { "errors": 0, "warnings": 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Test and evidence taxonomy
|
||||||
|
|
||||||
|
Each gate is blocking in its declared scope. Failures are not downgraded with
|
||||||
|
`continue-on-error` or warning-only scripts.
|
||||||
|
|
||||||
|
| Level | Command | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` |
|
||||||
|
| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` |
|
||||||
|
| component | `pnpm test:component` | `artifacts/tests/component.xml` |
|
||||||
|
| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` |
|
||||||
|
| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` |
|
||||||
|
| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` |
|
||||||
|
|
||||||
|
A control is verified only when a positive fixture passes and its deliberately
|
||||||
|
failing negative fixture is rejected. Generated evidence is retained by CI;
|
||||||
|
the repository tracks only the evidence directory structure.
|
||||||
|
|
||||||
|
Promotion is an AND graph:
|
||||||
|
|
||||||
|
1. merge gates
|
||||||
|
2. merge gates plus release gates
|
||||||
|
3. release gates plus rollback/runbook drills
|
||||||
|
4. production promotion plus eligible field Web Vitals evidence
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import eslint from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
const layerPatterns = {
|
||||||
|
domain: [
|
||||||
|
"**/application/**",
|
||||||
|
"**/presentation/**",
|
||||||
|
"**/adapters/**",
|
||||||
|
"**/bootstrap/**",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"@tanstack/**",
|
||||||
|
],
|
||||||
|
application: [
|
||||||
|
"**/presentation/**",
|
||||||
|
"**/adapters/**",
|
||||||
|
"**/bootstrap/**",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"@tanstack/**",
|
||||||
|
],
|
||||||
|
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"],
|
||||||
|
adapters: ["**/presentation/**", "**/bootstrap/**"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function restrictedImports(patterns) {
|
||||||
|
return ["error", { patterns }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"artifacts/**",
|
||||||
|
"tests/fixtures/typecheck/**",
|
||||||
|
"tests/fixtures/architecture/forbidden/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx,mjs}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/domain/**/*.{js,jsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": restrictedImports(layerPatterns.domain),
|
||||||
|
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/application/**/*.{js,jsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": restrictedImports(layerPatterns.application),
|
||||||
|
"no-restricted-globals": ["error", "window", "document", "localStorage", "fetch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/presentation/**/*.{js,jsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": restrictedImports(layerPatterns.presentation),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/adapters/**/*.{js,jsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": restrictedImports(layerPatterns.adapters),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["tests/**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["tests/fixtures/architecture/forbidden/**/*.{js,jsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": restrictedImports([
|
||||||
|
"**/adapters/**",
|
||||||
|
"@tanstack/**",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Clean Architecture frontend template" />
|
||||||
|
<title>Clean Architecture Frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/bootstrap/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "clean-architecture-frontend-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.17.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0 <25.0.0",
|
||||||
|
"pnpm": ">=11.0.0 <12.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && node scripts/generate-build-manifest.mjs",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
|
||||||
|
"check:architecture": "node scripts/check-architecture.mjs",
|
||||||
|
"check:types": "tsc --allowJs --checkJs --noEmit",
|
||||||
|
"check:types:fixture": "tsc --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
|
||||||
|
"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",
|
||||||
|
"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:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"zod": "4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.12.1",
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"@playwright/test": "1.62.0",
|
||||||
|
"@testing-library/jest-dom": "7.0.0",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@testing-library/user-event": "14.6.1",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"@types/react": "19.2.8",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.4",
|
||||||
|
"dependency-cruiser": "18.1.0",
|
||||||
|
"eslint": "10.8.0",
|
||||||
|
"globals": "17.7.0",
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"msw": "2.15.0",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
outputDir: "./artifacts/tests/e2e/results",
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "./artifacts/tests/e2e/report", open: "never" }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:5173",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev --host 127.0.0.1",
|
||||||
|
url: "http://127.0.0.1:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+2876
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
msw: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@playwright/test@1.62.0'
|
||||||
|
- playwright-core@1.62.0
|
||||||
|
- playwright@1.62.0
|
||||||
|
- eslint@10.8.0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"APP_ENV": "local",
|
||||||
|
"API_BASE_URL": "http://localhost:8080",
|
||||||
|
"REQUEST_TIMEOUT_MS": 10000,
|
||||||
|
"MAX_RETRY_ATTEMPTS": 2,
|
||||||
|
"TELEMETRY_ENABLED": false,
|
||||||
|
"AUTH_MODE": "external",
|
||||||
|
"CONFIG_SCHEMA_VERSION": "1",
|
||||||
|
"API_CONTRACT_VERSION": "1",
|
||||||
|
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||||
|
"BUILD_ID": "local-build",
|
||||||
|
"RELEASE_ID": "local-release"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
await mkdir("artifacts/quality", { recursive: true });
|
||||||
|
|
||||||
|
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
||||||
|
|
||||||
|
if (!pnpmCli) {
|
||||||
|
throw new Error("check:architecture must run through the pnpm script");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string[]} arguments_ */
|
||||||
|
function runPnpm(arguments_) {
|
||||||
|
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const production = runPnpm(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"depcruise",
|
||||||
|
"src",
|
||||||
|
"--config",
|
||||||
|
".dependency-cruiser.cjs",
|
||||||
|
"--output-type",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
"artifacts/quality/dependency-report.json",
|
||||||
|
production.stdout || JSON.stringify({ summary: { errors: 1 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (production.status !== 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
production.error?.message ?? production.stderr ?? production.stdout ?? "failed",
|
||||||
|
);
|
||||||
|
process.exit(production.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = runPnpm(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"eslint",
|
||||||
|
"tests/fixtures/architecture/allowed",
|
||||||
|
"--no-ignore",
|
||||||
|
"--max-warnings=0",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const forbidden = runPnpm(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"eslint",
|
||||||
|
"tests/fixtures/architecture/forbidden",
|
||||||
|
"--no-ignore",
|
||||||
|
"--max-warnings=0",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allowed.status !== 0 || forbidden.status === 0) {
|
||||||
|
process.stderr.write(allowed.stderr || allowed.stdout);
|
||||||
|
process.stderr.write(forbidden.stderr || forbidden.stdout);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n");
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||||
|
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
|
||||||
|
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
|
||||||
|
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
|
||||||
|
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
buildId,
|
||||||
|
commitSha,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
buildContext: {
|
||||||
|
nodeVersion: process.version,
|
||||||
|
packageManagerVersion,
|
||||||
|
runnerImage,
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
directory: "dist",
|
||||||
|
viteManifest: "dist/.vite/manifest.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir("artifacts/release", { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
"artifacts/release/build-manifest.json",
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Application facade factory. Concrete dependencies are supplied by bootstrap.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* resources: {
|
||||||
|
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
|
||||||
|
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
|
||||||
|
* },
|
||||||
|
* cache: import("./ports/query-cache-port.js").QueryCachePort,
|
||||||
|
* storage: import("./ports/storage-port.js").StoragePort,
|
||||||
|
* telemetry: import("./ports/telemetry-port.js").TelemetryPort
|
||||||
|
* }} ports
|
||||||
|
*/
|
||||||
|
export function createApplication(ports) {
|
||||||
|
/**
|
||||||
|
* @param {unknown} query
|
||||||
|
* @param {import("./ports/resource-ports.js").RequestContext} [context]
|
||||||
|
*/
|
||||||
|
function queryResources(query, context) {
|
||||||
|
return ports.resources.query.execute(query, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} command
|
||||||
|
* @param {import("./ports/resource-ports.js").RequestContext} [context]
|
||||||
|
*/
|
||||||
|
function commandResources(command, context) {
|
||||||
|
return ports.resources.command.execute(command, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
resources: Object.freeze({
|
||||||
|
query: queryResources,
|
||||||
|
command: commandResources,
|
||||||
|
}),
|
||||||
|
cache: ports.cache,
|
||||||
|
storage: ports.storage,
|
||||||
|
telemetry: ports.telemetry,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {"authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"} SessionState
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session is opaque: credentials are attached without exposing tokens.
|
||||||
|
*
|
||||||
|
* @typedef {{
|
||||||
|
* getState(): SessionState,
|
||||||
|
* attach(request: Request): Promise<Request>,
|
||||||
|
* recover(): Promise<"restored" | "no-session">,
|
||||||
|
* onUnauthenticated(): void
|
||||||
|
* }} AuthSessionPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* now(): number,
|
||||||
|
* sleep(milliseconds: number, signal?: AbortSignal): Promise<void>
|
||||||
|
* }} ClockPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {ClockPort} */
|
||||||
|
export const systemClock = Object.freeze({
|
||||||
|
now: () => Date.now(),
|
||||||
|
sleep(milliseconds, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(signal.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(resolve, milliseconds);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(signal.reason);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* read(key: readonly unknown[]): unknown,
|
||||||
|
* write(key: readonly unknown[], value: unknown): void,
|
||||||
|
* invalidate(namespace: readonly unknown[]): Promise<void>
|
||||||
|
* }} QueryCachePort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* getCurrent(): Promise<{
|
||||||
|
* buildId: string,
|
||||||
|
* configSchemaVersion: string,
|
||||||
|
* apiContractVersion: string,
|
||||||
|
* assetManifestHash: string,
|
||||||
|
* releaseId: string
|
||||||
|
* }>
|
||||||
|
* }} ReleaseInfoPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @template Query
|
||||||
|
* @template Model
|
||||||
|
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Command
|
||||||
|
* @template Model
|
||||||
|
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* operationId: string,
|
||||||
|
* routeId: string,
|
||||||
|
* signal?: AbortSignal,
|
||||||
|
* idempotencyKey?: string
|
||||||
|
* }} RequestContext
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Value
|
||||||
|
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
|
||||||
|
* { ok: false, error: unknown }} Result
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* read(logicalName: string): { ok: true, value: unknown } | { ok: false, error: unknown },
|
||||||
|
* write(logicalName: string, value: unknown): { ok: true } | { ok: false, error: unknown },
|
||||||
|
* remove(logicalName: string): { ok: true } | { ok: false, error: unknown }
|
||||||
|
* }} StoragePort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createApplication } from "../application/create-application.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the only module allowed to join concrete adapters to application
|
||||||
|
* ports. Boot phases are explicit so failures can stop before product mount.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* loadConfig(): Promise<Record<string, unknown>>,
|
||||||
|
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
|
||||||
|
* createAdapters(context: {
|
||||||
|
* config: Record<string, unknown>,
|
||||||
|
* release: Record<string, unknown>
|
||||||
|
* }): Promise<Parameters<typeof createApplication>[0]>
|
||||||
|
* }} factories
|
||||||
|
*/
|
||||||
|
export async function createCompositionRoot(factories) {
|
||||||
|
const config = await factories.loadConfig();
|
||||||
|
const release = await factories.loadRelease(config);
|
||||||
|
const ports = await factories.createAdapters({ config, release });
|
||||||
|
const application = createApplication(ports);
|
||||||
|
|
||||||
|
return Object.freeze({ config, release, ports, application });
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { assertSafeConfigNames, getBuildConfig } from "../contracts/env.js";
|
||||||
|
import { validateRuntimeConfig } from "./runtime-config-schema.js";
|
||||||
|
|
||||||
|
export class BootConfigError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param {{ buildId: string, configSchemaVersion?: string, releaseId?: string }} safe
|
||||||
|
*/
|
||||||
|
constructor(code, safe) {
|
||||||
|
super("Runtime configuration could not be loaded");
|
||||||
|
this.name = "BootConfigError";
|
||||||
|
this.kind = "BOOT_CONFIG_FAILURE";
|
||||||
|
this.code = code;
|
||||||
|
this.safe = Object.freeze({
|
||||||
|
kind: this.kind,
|
||||||
|
code,
|
||||||
|
buildId: safe.buildId,
|
||||||
|
configSchemaVersion: safe.configSchemaVersion,
|
||||||
|
releaseId: safe.releaseId,
|
||||||
|
supportReference: `${safe.buildId}:${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* fetcher?: typeof fetch,
|
||||||
|
* buildConfig?: ReturnType<typeof getBuildConfig>,
|
||||||
|
* now?: () => number
|
||||||
|
* }} [options]
|
||||||
|
*/
|
||||||
|
export async function loadRuntimeConfig(options = {}) {
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const buildConfig = options.buildConfig ?? getBuildConfig();
|
||||||
|
const now = options.now ?? performance.now.bind(performance);
|
||||||
|
const startedAt = now();
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetcher(buildConfig.runtimeConfigUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BootConfigError("CONFIG_FETCH_FAILED", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BootConfigError("CONFIG_HTTP_FAILED", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawConfig;
|
||||||
|
try {
|
||||||
|
rawConfig = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new BootConfigError("CONFIG_JSON_INVALID", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
|
||||||
|
throw new BootConfigError("CONFIG_SHAPE_INVALID", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertSafeConfigNames(rawConfig);
|
||||||
|
} catch {
|
||||||
|
throw new BootConfigError("CONFIG_SECRET_NAME_REJECTED", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateRuntimeConfig(rawConfig);
|
||||||
|
if (!validated.success) {
|
||||||
|
throw new BootConfigError("CONFIG_SCHEMA_INVALID", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
configSchemaVersion:
|
||||||
|
typeof rawConfig.CONFIG_SCHEMA_VERSION === "string"
|
||||||
|
? rawConfig.CONFIG_SCHEMA_VERSION
|
||||||
|
: undefined,
|
||||||
|
releaseId: typeof rawConfig.RELEASE_ID === "string" ? rawConfig.RELEASE_ID : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validated.data.BUILD_ID && validated.data.BUILD_ID !== buildConfig.buildId) {
|
||||||
|
throw new BootConfigError("CONFIG_BUILD_MISMATCH", {
|
||||||
|
buildId: buildConfig.buildId,
|
||||||
|
configSchemaVersion: validated.data.CONFIG_SCHEMA_VERSION,
|
||||||
|
releaseId: validated.data.RELEASE_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
config: validated.data,
|
||||||
|
build: buildConfig,
|
||||||
|
validationDurationMs: now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Missing #root mount element");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const runtime = await loadRuntimeConfig();
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<BootstrapShell environment={runtime.config.APP_ENV} />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const supportReference =
|
||||||
|
error instanceof BootConfigError
|
||||||
|
? error.safe.supportReference
|
||||||
|
: "boot:unknown";
|
||||||
|
|
||||||
|
root.render(<BootErrorShell supportReference={supportReference} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void boot();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||||
|
|
||||||
|
export const runtimeConfigSchema = z
|
||||||
|
.object({
|
||||||
|
APP_ENV: z.enum(["local", "development", "staging", "production"]),
|
||||||
|
API_BASE_URL: z.url(),
|
||||||
|
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
|
||||||
|
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
|
||||||
|
TELEMETRY_ENABLED: z.boolean(),
|
||||||
|
TELEMETRY_ENDPOINT: z.url().optional(),
|
||||||
|
AUTH_MODE: z.literal("external"),
|
||||||
|
CONFIG_SCHEMA_VERSION: version,
|
||||||
|
API_CONTRACT_VERSION: version,
|
||||||
|
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
|
||||||
|
RELEASE_ID: z.string().min(1).optional(),
|
||||||
|
BUILD_ID: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((config, context) => {
|
||||||
|
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["TELEMETRY_ENDPOINT"],
|
||||||
|
message: "required when telemetry is enabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
|
||||||
|
const endpointEntries =
|
||||||
|
/** @type {Array<[string, string | undefined]>} */ ([
|
||||||
|
["API_BASE_URL", config.API_BASE_URL],
|
||||||
|
["TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [key, value] of endpointEntries) {
|
||||||
|
if (value && !local && new URL(value).protocol !== "https:") {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: [key],
|
||||||
|
message: "HTTPS is required outside local environments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @param {unknown} value */
|
||||||
|
export function validateRuntimeConfig(value) {
|
||||||
|
const result = runtimeConfigSchema.safeParse(value);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: /** @type {false} */ (false),
|
||||||
|
issues: result.error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join("."),
|
||||||
|
code: issue.code,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: /** @type {true} */ (true),
|
||||||
|
data: structuredClone(result.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const forbiddenConfigName = /(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i;
|
||||||
|
|
||||||
|
export const ENV_REGISTRY = Object.freeze({
|
||||||
|
VITE_BUILD_ID: build("public-metadata", true, null),
|
||||||
|
VITE_COMMIT_SHA: build("public-metadata", false, "local"),
|
||||||
|
VITE_ROUTER_BASE_PATH: build("compile-time", true, "/"),
|
||||||
|
VITE_RUNTIME_CONFIG_URL: build("compile-time", true, "/config.json"),
|
||||||
|
APP_ENV: runtime("public", true, null),
|
||||||
|
API_BASE_URL: runtime("public-sensitive", true, null),
|
||||||
|
REQUEST_TIMEOUT_MS: runtime("public", false, 10_000),
|
||||||
|
MAX_RETRY_ATTEMPTS: runtime("public", false, 2),
|
||||||
|
TELEMETRY_ENABLED: runtime("public", true, false),
|
||||||
|
TELEMETRY_ENDPOINT: runtime("public-sensitive", false, null),
|
||||||
|
AUTH_MODE: runtime("public", true, "external"),
|
||||||
|
CONFIG_SCHEMA_VERSION: runtime("public", true, null),
|
||||||
|
API_CONTRACT_VERSION: runtime("public", true, null),
|
||||||
|
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} classification
|
||||||
|
* @param {boolean} required
|
||||||
|
* @param {unknown} defaultValue
|
||||||
|
*/
|
||||||
|
function build(classification, required, defaultValue) {
|
||||||
|
return Object.freeze({ phase: "build", classification, required, defaultValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} classification
|
||||||
|
* @param {boolean} required
|
||||||
|
* @param {unknown} defaultValue
|
||||||
|
*/
|
||||||
|
function runtime(classification, required, defaultValue) {
|
||||||
|
return Object.freeze({ phase: "runtime", classification, required, defaultValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} config */
|
||||||
|
export function assertSafeConfigNames(config) {
|
||||||
|
for (const name of Object.keys(config)) {
|
||||||
|
if (forbiddenConfigName.test(name)) {
|
||||||
|
throw new Error(`Forbidden client configuration key: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuildConfig(environment = import.meta.env) {
|
||||||
|
const buildId = environment.VITE_BUILD_ID || "local-build";
|
||||||
|
const commitSha = environment.VITE_COMMIT_SHA || "local";
|
||||||
|
const routerBasePath = environment.VITE_ROUTER_BASE_PATH || "/";
|
||||||
|
const runtimeConfigUrl = environment.VITE_RUNTIME_CONFIG_URL || "/config.json";
|
||||||
|
|
||||||
|
return Object.freeze({ buildId, commitSha, routerBasePath, runtimeConfigUrl });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
function TestShell() {
|
||||||
|
return <main aria-label="application shell">ready</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("component test level", () => {
|
||||||
|
it("renders an accessible application shell", () => {
|
||||||
|
render(<TestShell />);
|
||||||
|
expect(screen.getByRole("main", { name: "application shell" })).toHaveTextContent(
|
||||||
|
"ready",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("boots the public app shell", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||||
|
"Clean Architecture Frontend",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createApplication } from "../../../../src/application/create-application.js";
|
||||||
|
|
||||||
|
export const applicationFactory = createApplication;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const invalidClient = new QueryClient();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const invalidDomainValue = React.createElement("div");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import "../../../../src/adapters/http/client.js";
|
||||||
|
|
||||||
|
export const invalidEdge = true;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// This deliberately failing fixture proves that checkJs rejects a wrong shape.
|
||||||
|
|
||||||
|
/** @param {{ operationId: string }} request */
|
||||||
|
function execute(request) {
|
||||||
|
return request.operationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute({ operationId: 42 });
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
http.get("https://example.test/health", () =>
|
||||||
|
HttpResponse.json({ success: true, data: { status: "ok" } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe("integration test level", () => {
|
||||||
|
it("uses MSW to isolate the HTTP boundary", async () => {
|
||||||
|
const response = await fetch("https://example.test/health");
|
||||||
|
await expect(response.json()).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { status: "ok" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadRuntimeConfig } from "../../src/bootstrap/load-runtime-config.js";
|
||||||
|
import { validateRuntimeConfig } from "../../src/bootstrap/runtime-config-schema.js";
|
||||||
|
import { assertSafeConfigNames } from "../../src/contracts/env.js";
|
||||||
|
|
||||||
|
const validConfig = {
|
||||||
|
APP_ENV: "local",
|
||||||
|
API_BASE_URL: "http://localhost:8080",
|
||||||
|
REQUEST_TIMEOUT_MS: 10_000,
|
||||||
|
MAX_RETRY_ATTEMPTS: 2,
|
||||||
|
TELEMETRY_ENABLED: false,
|
||||||
|
AUTH_MODE: "external",
|
||||||
|
CONFIG_SCHEMA_VERSION: "1",
|
||||||
|
API_CONTRACT_VERSION: "1",
|
||||||
|
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||||
|
BUILD_ID: "build-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("runtime configuration boundary", () => {
|
||||||
|
it.each([
|
||||||
|
[{ ...validConfig, API_BASE_URL: undefined }, "required key"],
|
||||||
|
[{ ...validConfig, REQUEST_TIMEOUT_MS: 0 }, "integer range"],
|
||||||
|
[{ ...validConfig, MAX_RETRY_ATTEMPTS: 3 }, "retry cap"],
|
||||||
|
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
|
||||||
|
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
|
||||||
|
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
|
||||||
|
])("rejects invalid config: %s (%s)", (candidate) => {
|
||||||
|
expect(validateRuntimeConfig(candidate).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects secret-like names before schema validation", () => {
|
||||||
|
expect(() => assertSafeConfigNames({ CLIENT_SECRET: "not-safe" })).toThrow(
|
||||||
|
"Forbidden client configuration key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates a fetched config under the 500ms budget excluding network", async () => {
|
||||||
|
let current = 100;
|
||||||
|
const result = await loadRuntimeConfig({
|
||||||
|
buildConfig: {
|
||||||
|
buildId: "build-a",
|
||||||
|
commitSha: "local",
|
||||||
|
routerBasePath: "/",
|
||||||
|
runtimeConfigUrl: "/config.json",
|
||||||
|
},
|
||||||
|
fetcher: async () => new Response(JSON.stringify(validConfig)),
|
||||||
|
now: () => (current += 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.validationDurationMs).toBeLessThanOrEqual(500);
|
||||||
|
expect(result.config.API_BASE_URL).toBe("http://localhost:8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only safe boot fields on failure", async () => {
|
||||||
|
await expect(
|
||||||
|
loadRuntimeConfig({
|
||||||
|
buildConfig: {
|
||||||
|
buildId: "build-a",
|
||||||
|
commitSha: "local",
|
||||||
|
routerBasePath: "/",
|
||||||
|
runtimeConfigUrl: "/config.json",
|
||||||
|
},
|
||||||
|
fetcher: async () => new Response("{"),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
safe: {
|
||||||
|
kind: "BOOT_CONFIG_FAILURE",
|
||||||
|
buildId: "build-a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { systemClock } from "../../src/application/ports/clock-port.js";
|
||||||
|
|
||||||
|
describe("systemClock", () => {
|
||||||
|
it("resolves after the requested duration", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const sleeper = systemClock.sleep(250);
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
await expect(sleeper).resolves.toBeUndefined();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "scripts", "vite.config.js"],
|
||||||
|
"exclude": ["dist", "node_modules", "tests/fixtures"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
manifest: true,
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: false,
|
||||||
|
setupFiles: ["./tests/setup.js"],
|
||||||
|
restoreMocks: true,
|
||||||
|
clearMocks: true,
|
||||||
|
mockReset: true,
|
||||||
|
testTimeout: 10_000,
|
||||||
|
coverage: {
|
||||||
|
reporter: ["text", "json-summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user