Compare commits

...
37 changed files with 2578 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.vite/
playwright-report/
test-results/
coverage/
artifacts/**/*.json
artifacts/**/*.xml
artifacts/**/*.txt
artifacts/**/*.sarif
!artifacts/**/.gitkeep
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
save-exact=true
+1
View File
@@ -0,0 +1 @@
24.14.0
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+38
View File
@@ -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"]
]
}
+34
View File
@@ -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 }
}
```
+24
View File
@@ -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
+13
View File
@@ -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>
+45
View File
@@ -0,0 +1,45 @@
{
"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",
"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"
},
"devDependencies": {
"@axe-core/playwright": "4.12.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",
"jsdom": "29.1.1",
"msw": "2.15.0",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
}
}
+23
View File
@@ -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"] } },
],
});
+2029
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
msw: true
minimumReleaseAgeExclude:
- '@playwright/test@1.62.0'
- playwright-core@1.62.0
- playwright@1.62.0
+30
View File
@@ -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`,
);
+40
View File
@@ -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 {};
+29
View File
@@ -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 {};
+28
View File
@@ -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 {};
+9
View File
@@ -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 {};
+5
View File
@@ -0,0 +1,5 @@
/**
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
*/
export {};
+23
View File
@@ -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 });
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
function BootstrapShell() {
return (
<main>
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약을 불러오는 중입니다.</p>
</main>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root mount element");
}
createRoot(rootElement).render(
<StrictMode>
<BootstrapShell />
</StrictMode>,
);
+17
View File
@@ -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",
);
});
});
+8
View File
@@ -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",
);
});
+8
View File
@@ -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 });
+23
View File
@@ -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" },
});
});
});
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+13
View File
@@ -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();
});
});
+20
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
manifest: true,
sourcemap: false,
},
});
+15
View File
@@ -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"],
},
},
});