Compare commits

..
18 changed files with 2461 additions and 2 deletions
+49
View File
@@ -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"],
},
},
};
+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
+101
View File
@@ -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",
]),
},
},
];
+23 -2
View File
@@ -12,19 +12,40 @@
"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"
"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",
"@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"
"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"] } },
],
});
+2070
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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
+69
View File
@@ -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");
+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",
);
});
@@ -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;
+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();
});
});
+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"],
},
},
});