Compare commits

...
Author SHA1 Message Date
donghyeon-ka c6b7a9b9bc feat: add removable sample vertical contract fixture 2026-07-25 21:07:48 +09:00
donghyeon-ka 04c4bad43c Merge branch 'feature-frontend-render-recovery-boundary-contract' into develop 2026-07-25 21:05:36 +09:00
donghyeon-ka c37d571eb3 feat: add layered render recovery boundaries 2026-07-25 21:05:36 +09:00
donghyeon-ka eb16c2ffe7 Merge branch 'feature-routing-navigation-guard-contract' into develop 2026-07-25 21:03:57 +09:00
donghyeon-ka b221453c15 feat: centralize routes and navigation guards 2026-07-25 21:03:57 +09:00
donghyeon-ka bfc642875c Merge branch 'feature-boundary-mapper-viewmodel-contract' into develop 2026-07-25 21:02:06 +09:00
donghyeon-ka a9a7db0231 feat: contain DTO mapping at the HTTP boundary 2026-07-25 21:02:06 +09:00
donghyeon-ka 3e126c0ddd Merge branch 'feature-async-ui-state-contract' into develop 2026-07-25 21:00:21 +09:00
donghyeon-ka 438dc11548 feat: model complete async UI surface states 2026-07-25 21:00:21 +09:00
donghyeon-ka 652e0250f3 Merge branch 'feature-frontend-observability-logging-trace-contract' into develop 2026-07-25 20:58:33 +09:00
donghyeon-ka bf8d69e285 feat: add redacted best-effort telemetry contract 2026-07-25 20:58:33 +09:00
donghyeon-ka d54eeff450 Merge branch 'feature-frontend-storage-registry-contract' into develop 2026-07-25 20:56:58 +09:00
donghyeon-ka 2c3eda4d6b feat: enforce classified browser storage registry 2026-07-25 20:56:58 +09:00
donghyeon-ka 0a3bf08308 Merge branch 'feature-server-state-caching-contract' into develop 2026-07-25 20:55:27 +09:00
36 changed files with 1995 additions and 39 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
.tmp/
playwright-report/ playwright-report/
test-results/ test-results/
coverage/ coverage/
+2
View File
@@ -22,12 +22,14 @@
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y", "test:a11y": "playwright test --grep @a11y",
"test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.1",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
+39
View File
@@ -17,6 +17,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8(react@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: zod:
specifier: 4.4.3 specifier: 4.4.3
version: 4.4.3 version: 4.4.3
@@ -1260,6 +1263,23 @@ packages:
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} 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: react@19.2.8:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1312,6 +1332,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.1.2: set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -2604,6 +2627,20 @@ snapshots:
react-is@17.0.2: {} 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: {} react@19.2.8: {}
rechoir@0.8.0: rechoir@0.8.0:
@@ -2663,6 +2700,8 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.2: {} set-cookie-parser@3.1.2: {}
shebang-command@2.0.0: shebang-command@2.0.0:
+77
View File
@@ -0,0 +1,77 @@
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"));
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");
+34 -7
View File
@@ -3,7 +3,9 @@ import { getApiOperation } from "../../contracts/api-operations.js";
import { import {
createFailure as failure, createFailure as failure,
kindForStatus as statusKind, kindForStatus as statusKind,
normalizeUnknownFailure,
} from "../../contracts/errors.js"; } from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js"; import { retryDelay, shouldRetry } from "./retry-policy.js";
import { import {
validateEnvelope, validateEnvelope,
@@ -49,6 +51,7 @@ const noAuthSession =
* random?: () => number, * random?: () => number,
* validatePayload?: (schemaId: string, value: unknown) => * validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }, * { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string * idempotencyKeyFactory?: () => string
* }} dependencies * }} dependencies
*/ */
@@ -59,6 +62,7 @@ export function createHttpClient(dependencies) {
const random = dependencies.random ?? Math.random; const random = dependencies.random ?? Math.random;
const validatePayload = const validatePayload =
dependencies.validatePayload ?? validateOperationPayload; dependencies.validatePayload ?? validateOperationPayload;
const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory = const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID());
@@ -195,7 +199,13 @@ export function createHttpClient(dependencies) {
} }
const response = await fetcher(request); const response = await fetcher(request);
return await parseResponse(response, operation, attempt, validatePayload); return await parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
);
} catch { } catch {
if (timedOut) { if (timedOut) {
return { return {
@@ -254,9 +264,16 @@ export function createHttpClient(dependencies) {
* @param {number} attempt * @param {number} attempt
* @param {(schemaId: string, value: unknown) => * @param {(schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }} validatePayload * { success: true, data: unknown } | { success: false }} validatePayload
* @param {(operationId: string, payload: unknown) => unknown} mapPayload
* @returns {Promise<HttpResult>} * @returns {Promise<HttpResult>}
*/ */
async function parseResponse(response, operation, attempt, validatePayload) { async function parseResponse(
response,
operation,
attempt,
validatePayload,
mapPayload,
) {
const contentType = response.headers.get("content-type") ?? ""; const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) { if (!contentType.toLowerCase().includes("application/json")) {
return { return {
@@ -311,11 +328,21 @@ async function parseResponse(response, operation, attempt, validatePayload) {
}; };
} }
return { try {
ok: true, return {
value: structuredClone(payload.data), ok: true,
meta: safeMeta(envelopeRecord.meta), value: mapPayload(operation.operationId, payload.data),
}; meta: safeMeta(envelopeRecord.meta),
};
} catch (error) {
return {
ok: false,
error: normalizeUnknownFailure(error, {
operationId: operation.operationId,
attempt,
}),
};
}
} }
const kind = statusKind(response.status); const kind = statusKind(response.status);
+30
View File
@@ -0,0 +1,30 @@
import { createResource } from "../../domain/models/resource.js";
/** @param {unknown} value */
export function mapResourceDto(value) {
if (!value || typeof value !== "object") {
throw new TypeError("Validated resource DTO is required");
}
const dto = /** @type {Record<string, unknown>} */ (value);
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated resource DTO invariants were breached");
}
return createResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
/** @param {string} operationId @param {unknown} payload */
export function mapOperationPayload(operationId, payload) {
if (operationId === "LIST_SAMPLE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a resource list");
return payload.map(mapResourceDto);
}
if (operationId === "CREATE_SAMPLE_RESOURCE") {
return mapResourceDto(payload);
}
throw new TypeError(`No boundary mapper registered for ${operationId}`);
}
@@ -0,0 +1,137 @@
import { createFailure } from "../../contracts/errors.js";
import { getStorageDefinition } from "../../contracts/storage-keys.js";
/**
* @param {{
* localStorage?: Storage,
* sessionStorage?: Storage,
* now?: () => number
* }} [dependencies]
* @returns {import("../../application/ports/storage-port.js").StoragePort}
*/
export function createBrowserStorageAdapter(dependencies = {}) {
const memory = new Map();
const now = dependencies.now ?? Date.now;
/** @param {string} name */
function backendFor(name) {
if (name === "localStorage") return dependencies.localStorage;
if (name === "sessionStorage") return dependencies.sessionStorage;
return undefined;
}
return Object.freeze({
read(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("read", logicalName);
}
const backend = backendFor(definition.backend);
try {
const raw = backend?.getItem(definition.physicalKey);
if (raw === null || raw === undefined) {
return { ok: true, value: memory.get(definition.physicalKey) };
}
const envelope = JSON.parse(raw);
if (
!envelope ||
typeof envelope !== "object" ||
envelope.schemaVersion !== definition.schemaVersion
) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) {
backend?.removeItem(definition.physicalKey);
return { ok: true, value: undefined };
}
return { ok: true, value: structuredClone(envelope.value) };
} catch {
return unavailable("read", logicalName);
}
},
write(logicalName, value) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("write", logicalName);
}
const expiresAt =
typeof definition.ttl === "number" ? now() + definition.ttl : null;
const envelope = {
schemaVersion: definition.schemaVersion,
expiresAt,
value: structuredClone(value),
};
const backend = backendFor(definition.backend);
try {
if (!backend) throw new DOMException("Storage unavailable", "SecurityError");
backend.setItem(definition.physicalKey, JSON.stringify(envelope));
return { ok: true };
} catch (error) {
const quota =
error instanceof DOMException &&
["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name);
if (definition.quotaFallback === "memory") {
memory.set(definition.physicalKey, structuredClone(value));
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: "memory",
};
}
return {
ok: false,
error: storageFailure(quota, "write", logicalName),
fallback: definition.quotaFallback,
};
}
},
remove(logicalName) {
let definition;
try {
definition = getStorageDefinition(logicalName);
} catch {
return unavailable("remove", logicalName);
}
try {
backendFor(definition.backend)?.removeItem(definition.physicalKey);
memory.delete(definition.physicalKey);
return { ok: true };
} catch {
return unavailable("remove", logicalName);
}
},
});
}
/** @param {boolean} quota @param {string} phase @param {string} logicalName */
function storageFailure(quota, phase, logicalName) {
return createFailure(
quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE",
"STORAGE",
0,
{
code: `${logicalName}_${phase.toUpperCase()}_${
quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE"
}`,
},
);
}
/** @param {string} phase @param {string} logicalName */
function unavailable(phase, logicalName) {
return {
ok: /** @type {false} */ (false),
error: storageFailure(false, phase, logicalName),
};
}
@@ -0,0 +1,99 @@
import { projectTelemetryEvent } from "../../contracts/telemetry.js";
export const noOpTelemetry = Object.freeze({
emit: () => {},
});
/**
* @param {{
* enabled: boolean,
* endpoint?: string,
* fetcher?: typeof fetch,
* maxQueue?: number,
* schedule?: (callback: () => void) => void
* }} options
*/
export function createTelemetryAdapter(options) {
if (!options.enabled || !options.endpoint) {
return Object.freeze({
...noOpTelemetry,
flush: async () => {},
pendingCount: () => 0,
droppedCount: () => 0,
});
}
const endpoint = /** @type {string} */ (options.endpoint);
const fetcher = options.fetcher ?? fetch;
const maxQueue = options.maxQueue ?? 100;
const schedule = options.schedule ?? queueMicrotask;
const queue =
/** @type {Array<{eventName: string, attributes: Readonly<Record<string, unknown>>}>} */ (
[]
);
let scheduled = false;
let flushing = false;
let dropped = 0;
/** @param {string} eventName @param {Record<string, unknown>} attributes */
function emit(eventName, attributes) {
const projected = projectTelemetryEvent(eventName, attributes);
if (!projected.success) {
dropped += 1;
return;
}
if (queue.length >= maxQueue) {
queue.shift();
dropped += 1;
}
queue.push(projected.event);
if (!scheduled) {
scheduled = true;
schedule(() => {
scheduled = false;
void flush();
});
}
}
async function flush() {
if (flushing || queue.length === 0) return;
flushing = true;
const batch = queue.splice(0, queue.length);
try {
const response = await fetcher(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: batch }),
keepalive: true,
});
if (!response.ok) dropped += batch.length;
} catch {
dropped += batch.length;
} finally {
flushing = false;
}
}
return Object.freeze({
emit,
flush,
pendingCount: () => queue.length,
droppedCount: () => dropped,
});
}
/**
* Propagates only a structurally valid W3C traceparent. Invalid/raw headers are
* discarded rather than logged or surfaced.
*
* @param {string | null | undefined} traceparent
*/
export function safeTraceparent(traceparent) {
return typeof traceparent === "string" &&
/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/i.test(traceparent)
? traceparent.toLowerCase()
: null;
}
+7 -3
View File
@@ -1,8 +1,12 @@
/** /**
* @typedef {{ * @typedef {{
* read(logicalName: string): { ok: true, value: unknown } | { ok: false, error: unknown }, * read(logicalName: string): { ok: true, value: unknown } |
* write(logicalName: string, value: unknown): { ok: true } | { ok: false, error: unknown }, * { ok: false, error: import("../../contracts/errors.js").ApiFailure },
* remove(logicalName: string): { ok: true } | { ok: false, error: unknown } * write(logicalName: string, value: unknown): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure,
* fallback?: string },
* remove(logicalName: string): { ok: true } |
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }
* }} StoragePort * }} StoragePort
*/ */
@@ -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 };
}
@@ -0,0 +1,81 @@
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
]);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
]);
/**
* @typedef {{
* data?: unknown,
* isInitialLoading?: boolean,
* failure?: import("../../contracts/errors.js").ApiFailure,
* isFetching?: boolean,
* isStale?: boolean,
* isDegraded?: boolean,
* isMutationPending?: boolean,
* hasMutationConflict?: boolean
* }} AsyncSignals
*/
/** @param {AsyncSignals} signals */
export function deriveAsyncState(signals) {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
let base;
if (signals.isInitialLoading && !hasData) {
base = "initial-loading";
} else if (signals.failure && !hasData) {
base = "terminal-error";
} else if (empty) {
base = "empty";
} else if (hasData) {
base = "success";
} else {
base = "initial-loading";
}
const overlay = Object.freeze({
refreshing: Boolean(signals.isFetching && hasData),
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
mutationPending: Boolean(signals.isMutationPending && hasData),
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
});
const state = {
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator: selectOverlayIndicator(overlay),
};
return Object.freeze(state);
}
/**
* @param {{
* refreshing: boolean,
* staleDegraded: boolean,
* mutationPending: boolean,
* mutationConflict: boolean
* }} overlay
*/
export function selectOverlayIndicator(overlay) {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
@@ -0,0 +1,16 @@
/**
* @param {import("../../domain/models/resource.js").Resource} resource
* @param {(value: Date) => string} [formatDate]
*/
export function toResourceViewModel(
resource,
formatDate = (value) => new Intl.DateTimeFormat("ko-KR").format(value),
) {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
});
}
+11 -25
View File
@@ -1,28 +1,11 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; 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"; 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"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
@@ -36,16 +19,19 @@ async function boot() {
const runtime = await loadRuntimeConfig(); const runtime = await loadRuntimeConfig();
root.render( root.render(
<StrictMode> <StrictMode>
<BootstrapShell environment={runtime.config.APP_ENV} /> <AppRouter
authSession={createAnonymousSessionAdapter()}
basename={runtime.build.routerBasePath}
/>
</StrictMode>, </StrictMode>,
); );
} catch (error) { } catch (error) {
const supportReference = const safe =
error instanceof BootConfigError error instanceof BootConfigError
? error.safe.supportReference ? error.safe
: "boot:unknown"; : { supportReference: "boot:unknown" };
root.render(<BootErrorShell supportReference={supportReference} />); root.render(<BootErrorShell {...safe} />);
} }
} }
+8 -3
View File
@@ -9,13 +9,18 @@ const DROP_SENSITIVE = Object.freeze([
"storageValue", "storageValue",
]); ]);
/**
* @typedef {"retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none"} ErrorAction
*/
/** /**
* @typedef {{ * @typedef {{
* kind: string, * kind: string,
* defaultRetryable: boolean, * defaultRetryable: boolean,
* severity: string, * severity: string,
* userMessageKey: string, * userMessageKey: string,
* action: string, * action: ErrorAction,
* telemetryEvent: string, * telemetryEvent: string,
* redaction: readonly string[] * redaction: readonly string[]
* }} ErrorDefinition * }} ErrorDefinition
@@ -25,7 +30,7 @@ const DROP_SENSITIVE = Object.freeze([
* @param {string} kind * @param {string} kind
* @param {boolean} defaultRetryable * @param {boolean} defaultRetryable
* @param {string} severity * @param {string} severity
* @param {string} action * @param {ErrorAction} action
* @param {string} [telemetryEvent] * @param {string} [telemetryEvent]
* @returns {Readonly<ErrorDefinition>} * @returns {Readonly<ErrorDefinition>}
*/ */
@@ -156,7 +161,7 @@ export const ERROR_REGISTRY = Object.freeze({
* traceId?: string, * traceId?: string,
* retryAfterMs?: number, * retryAfterMs?: number,
* userMessageKey: string, * userMessageKey: string,
* action: string, * action: ErrorAction,
* causeClass?: string * causeClass?: string
* }} ApiFailure * }} ApiFailure
*/ */
+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;
}
+102
View File
@@ -0,0 +1,102 @@
const APP_NAMESPACE = "ca-frontend";
export const STORAGE_REGISTRY = Object.freeze({
COLOR_SCHEME: defineStorageKey({
logicalName: "COLOR_SCHEME",
scope: "preference",
name: "color-scheme",
backend: "localStorage",
classification: "public-preference",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "memory",
}),
CHUNK_RELOAD_GUARD: defineStorageKey({
logicalName: "CHUNK_RELOAD_GUARD",
scope: "release",
name: "chunk-reload-guard",
backend: "sessionStorage",
classification: "opaque-cache",
schemaVersion: 1,
ttl: "session",
migration: "discard",
quotaFallback: "no-persist",
}),
QUERY_PERSISTENCE: defineStorageKey({
logicalName: "QUERY_PERSISTENCE",
scope: "cache",
name: "query-persistence",
backend: "disabled",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
AUTH_TOKEN: defineStorageKey({
logicalName: "AUTH_TOKEN",
scope: "auth",
name: "auth-token",
backend: "forbidden",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
});
/**
* @typedef {{
* logicalName: string,
* scope: string,
* name: string,
* backend: "memory" | "sessionStorage" | "localStorage" | "indexedDB" |
* "disabled" | "forbidden",
* classification: "public-preference" | "opaque-cache" | "sensitive-forbidden",
* schemaVersion: number,
* ttl: number | "session" | null,
* migration: "discard" | ((value: unknown) => unknown),
* quotaFallback: "memory" | "no-persist" | "feature-disable"
* }} StorageKeyInput
*/
/** @param {StorageKeyInput} definition */
export function defineStorageKey(definition) {
if (definition.classification === "sensitive-forbidden") {
if (!["disabled", "forbidden"].includes(definition.backend)) {
throw new Error("Sensitive client storage registration is forbidden");
}
}
if (!Number.isInteger(definition.schemaVersion) || definition.schemaVersion < 1) {
throw new Error("Storage schemaVersion must be a positive integer");
}
return Object.freeze({
...definition,
physicalKey: buildPhysicalKey(
definition.scope,
definition.schemaVersion,
definition.name,
),
});
}
/** @param {string} scope @param {number} schemaVersion @param {string} name */
export function buildPhysicalKey(scope, schemaVersion, name) {
return `${APP_NAMESPACE}:${scope}:v${schemaVersion}:${name}`;
}
/** @param {string} logicalName */
export function getStorageDefinition(logicalName) {
const registry = /** @type {Record<string, ReturnType<typeof defineStorageKey>>} */ (
STORAGE_REGISTRY
);
const definition = registry[logicalName];
if (!definition) throw new Error(`Unregistered storage key: ${logicalName}`);
if (definition.classification === "sensitive-forbidden") {
throw new Error(`Forbidden storage key: ${logicalName}`);
}
return definition;
}
+145
View File
@@ -0,0 +1,145 @@
export const TELEMETRY_ATTRIBUTE_ALLOWLIST = Object.freeze([
"app_version",
"build_id",
"release_id",
"config_schema_version",
"api_contract_version",
"route_id",
"operation_id",
"error_kind",
"http_status_group",
"attempt_count_bucket",
"duration_bucket",
"component_boundary",
"active_release_id",
"mismatch_kind",
"reason",
"queue_size_bucket",
]);
export const TELEMETRY_FORBIDDEN_ATTRIBUTES = Object.freeze([
"access_token",
"refresh_token",
"authorization_header",
"cookie",
"email",
"user_name",
"raw_user_id",
"raw_url",
"query_string",
"request_body",
"response_body",
"storage_value",
"stack_in_user_message",
]);
/**
* @typedef {{
* eventName: string,
* trigger: string,
* requiredAttributes: readonly string[],
* optionalAttributes: readonly string[],
* forbiddenAttributes: readonly string[],
* sampling: string,
* delivery: string
* }} TelemetryDefinition
*/
/**
* @param {string} eventName
* @param {string} trigger
* @param {string[]} requiredAttributes
* @param {string[]} [optionalAttributes]
* @param {string} [sampling]
* @returns {Readonly<TelemetryDefinition>}
*/
const event = (
eventName,
trigger,
requiredAttributes,
optionalAttributes = [],
sampling = "all",
) =>
Object.freeze({
eventName,
trigger,
requiredAttributes: Object.freeze(requiredAttributes),
optionalAttributes: Object.freeze(optionalAttributes),
forbiddenAttributes: TELEMETRY_FORBIDDEN_ATTRIBUTES,
sampling,
delivery: "best-effort",
});
export const TELEMETRY_REGISTRY = Object.freeze({
"app.boot.failed": event("app.boot.failed", "boot validation failure", [
"error_kind",
"build_id",
"config_schema_version",
]),
"api.request.failed": event("api.request.failed", "terminal API failure", [
"error_kind",
"http_status_group",
"attempt_count_bucket",
"route_id",
]),
"ui.render.failed": event("ui.render.failed", "React boundary catch", [
"route_id",
"build_id",
"component_boundary",
]),
"release.mismatch.detected": event(
"release.mismatch.detected",
"release tuple mismatch",
["build_id", "active_release_id", "mismatch_kind"],
),
"telemetry.delivery.dropped": event(
"telemetry.delivery.dropped",
"queue or sink failure",
["reason", "queue_size_bucket"],
[],
"internal-counter",
),
});
/**
* @param {string} eventName
* @param {Record<string, unknown>} attributes
*/
export function projectTelemetryEvent(eventName, attributes) {
const registry =
/** @type {Record<string, (typeof TELEMETRY_REGISTRY)[keyof typeof TELEMETRY_REGISTRY]>} */ (
TELEMETRY_REGISTRY
);
const definition = registry[eventName];
if (!definition) {
return {
success: /** @type {false} */ (false),
reason: "unregistered-event",
};
}
const projected = Object.fromEntries(
Object.entries(attributes).filter(
([key]) =>
TELEMETRY_ATTRIBUTE_ALLOWLIST.includes(key) &&
!TELEMETRY_FORBIDDEN_ATTRIBUTES.includes(key),
),
);
const missing = definition.requiredAttributes.filter(
(key) => projected[key] === undefined,
);
if (missing.length > 0) {
return {
success: /** @type {false} */ (false),
reason: "missing-required-attributes",
};
}
return {
success: /** @type {true} */ (true),
event: Object.freeze({
eventName,
attributes: Object.freeze(projected),
}),
};
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @typedef {{
* id: string,
* displayName: string,
* createdAt: string | null
* }} Resource
*/
/** @param {Resource} values @returns {Readonly<Resource>} */
export function createResource(values) {
if (!values.id || !values.displayName) {
throw new TypeError("Resource invariants require id and displayName");
}
return Object.freeze({
id: values.id,
displayName: values.displayName,
createdAt: values.createdAt,
});
}
@@ -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" />;
}
@@ -0,0 +1,72 @@
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
return (
<section aria-busy="true" aria-label={label}>
<div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span>
</section>
);
}
/** @param {{ title?: string, action?: React.ReactNode }} props */
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
return (
<section>
<p>{title}</p>
{action}
</section>
);
}
/**
* @param {{
* userMessageKey: string,
* action: "retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none",
* onAction?: () => void
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
return (
<section role="alert" aria-labelledby="terminal-error-message">
<p id="terminal-error-message">{userMessageKey}</p>
{action !== "none" && (
<button type="button" onClick={onAction}>
{action}
</button>
)}
</section>
);
}
/**
* @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode,
* onAction?: () => void
* }} props
*/
export function AsyncSurface({ state, children, onAction }) {
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
return (
<TerminalErrorSurface
userMessageKey={state.failure.userMessageKey}
action={state.failure.action}
onAction={onAction}
/>
);
}
return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && (
<p role="status" aria-live="polite">
{state.indicator}
</p>
)}
{children}
</section>
);
}
+79
View File
@@ -0,0 +1,79 @@
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>
<h1>Clean Architecture Frontend</h1>
<p>런타임 계약이 검증되었습니다.</p>
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
</main>
);
}
function SamplePlaceholder() {
return (
<main>
<h1>샘플 리소스</h1>
<p>계약 fixture를 준비하고 있습니다.</p>
</main>
);
}
function NotFoundPage() {
return (
<main>
<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>
<h1>세션이 필요합니다.</h1>
<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();
},
});
}
@@ -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>
);
}
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
import { createFailure } from "../../src/contracts/errors.js";
describe("async UI state matrix", () => {
it.each([
[{ isInitialLoading: true }, "initial-loading"],
[{ data: [{ id: "1" }] }, "success"],
[{ data: [] }, "empty"],
[
{ failure: createFailure("SERVER_FAILURE", "LIST", 0) },
"terminal-error",
],
])("derives base state %#", (signals, expected) => {
expect(deriveAsyncState(signals).base).toBe(expected);
});
it.each([
[{ data: ["value"], isFetching: true }, "refreshing"],
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
])("derives overlay state %#", (signals, indicator) => {
expect(deriveAsyncState(signals).indicator).toBe(indicator);
});
it("uses deterministic overlay priority for crossed states", () => {
const state = deriveAsyncState({
data: ["value"],
isFetching: true,
isMutationPending: true,
hasMutationConflict: true,
});
expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({
refreshing: true,
mutationPending: true,
mutationConflict: true,
});
});
it("keeps content visible while a non-blocking refresh runs", () => {
const state = deriveAsyncState({ data: ["value"], isFetching: true });
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
expect(screen.getByText("existing content")).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
});
it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE",
});
const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />);
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
expect(screen.getByRole("button")).toHaveTextContent("retry");
expect(screen.getByRole("alert")).not.toHaveTextContent("stack");
});
it("does not retain a terminal error after usable data is restored", () => {
const failed = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
const recovered = deriveAsyncState({ data: ["value"] });
expect(failed.base).toBe("terminal-error");
expect(recovered.base).toBe("success");
expect(recovered.failure).toBeUndefined();
});
});
+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");
});
});
+27 -1
View File
@@ -46,7 +46,7 @@ describe("shared HTTP client", () => {
client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }), client.execute("LIST_SAMPLE_RESOURCES", { routeId: "SAMPLE_RESOURCE_LIST" }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
ok: true, ok: true,
value: [{ id: "resource-1" }], value: [{ id: "resource-1", displayName: "Example" }],
meta: { requestId: "request-1" }, meta: { requestId: "request-1" },
}); });
expect(attempts).toBe(3); expect(attempts).toBe(3);
@@ -99,4 +99,30 @@ describe("shared HTTP client", () => {
error: { kind: "SCHEMA_MISMATCH" }, error: { kind: "SCHEMA_MISMATCH" },
}); });
}); });
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
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" },
}),
),
);
const client = createHttpClient({
baseUrl: "https://api.test",
clock,
mapPayload: () => {
throw new Error("raw mapper detail");
},
});
const result = await client.execute("LIST_SAMPLE_RESOURCES");
expect(result).toMatchObject({
ok: false,
error: { kind: "UNKNOWN_FAILURE" },
});
expect(JSON.stringify(result)).not.toContain("raw mapper detail");
});
}); });
@@ -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 {
mapOperationPayload,
mapResourceDto,
} from "../../src/adapters/http/resource-mapper.js";
import { toResourceViewModel } from "../../src/application/view-models/resource-view-model.js";
describe("DTO to model to view-model mapping", () => {
it("contains raw DTO names at the HTTP boundary", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
backendOnly: "not propagated",
});
expect(model).toEqual({
id: "resource-1",
displayName: "Example",
createdAt: "2026-07-25T00:00:00.000Z",
});
expect(model).not.toHaveProperty("name");
expect(model).not.toHaveProperty("backendOnly");
});
it("maps operation payloads and rejects missing mappers", () => {
expect(
mapOperationPayload("LIST_SAMPLE_RESOURCES", [
{ id: "resource-1", name: "Example" },
]),
).toEqual([
{ id: "resource-1", displayName: "Example", createdAt: null },
]);
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
"No boundary mapper registered",
);
});
it("projects an application-owned render-ready shape", () => {
const model = mapResourceDto({
id: "resource-1",
name: "Example",
createdAt: null,
});
expect(toResourceViewModel(model)).toEqual({
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
});
});
});
+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 });
});
});
+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);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.js";
import {
STORAGE_REGISTRY,
buildPhysicalKey,
defineStorageKey,
} from "../../src/contracts/storage-keys.js";
function createStorage({ quota = false } = {}) {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
if (quota) throw new DOMException("full", "QuotaExceededError");
values.set(key, value);
},
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
key: () => null,
get length() {
return values.size;
},
};
}
describe("storage registry", () => {
it("builds namespace and schema-versioned physical keys", () => {
expect(buildPhysicalKey("preference", 2, "theme")).toBe(
"ca-frontend:preference:v2:theme",
);
expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:");
});
it("rejects token or secret persistence registrations", () => {
expect(() =>
defineStorageKey({
logicalName: "TOKEN",
scope: "auth",
name: "token",
backend: "localStorage",
classification: "sensitive-forbidden",
schemaVersion: 1,
ttl: null,
migration: "discard",
quotaFallback: "feature-disable",
}),
).toThrow("Sensitive client storage registration is forbidden");
});
it("round-trips public preferences through the adapter", () => {
const localStorage = createStorage();
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true });
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
});
it("falls back to memory when preference storage quota is exceeded", () => {
const localStorage = createStorage({ quota: true });
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
ok: false,
fallback: "memory",
error: { kind: "STORAGE_QUOTA_EXCEEDED" },
});
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
});
it("discards data from a previous schema version", () => {
const localStorage = createStorage();
localStorage.setItem(
STORAGE_REGISTRY.COLOR_SCHEME.physicalKey,
JSON.stringify({ schemaVersion: 0, value: "dark" }),
);
const adapter = createBrowserStorageAdapter({ localStorage });
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined });
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import {
createTelemetryAdapter,
safeTraceparent,
} from "../../src/adapters/telemetry/best-effort-telemetry.js";
import {
TELEMETRY_REGISTRY,
projectTelemetryEvent,
} from "../../src/contracts/telemetry.js";
const validAttributes = {
error_kind: "SERVER_FAILURE",
http_status_group: "5xx",
attempt_count_bucket: "3",
route_id: "SAMPLE_RESOURCE_LIST",
};
describe("telemetry registry and redaction", () => {
it("defines all event contract fields", () => {
for (const definition of Object.values(TELEMETRY_REGISTRY)) {
expect(definition).toEqual(
expect.objectContaining({
eventName: expect.any(String),
trigger: expect.any(String),
requiredAttributes: expect.any(Array),
optionalAttributes: expect.any(Array),
forbiddenAttributes: expect.any(Array),
sampling: expect.any(String),
delivery: "best-effort",
}),
);
}
});
it("uses a default-deny attribute projection", () => {
const projected = projectTelemetryEvent("api.request.failed", {
...validAttributes,
raw_url: "https://api.test/path?token=secret",
unregistered: "private",
});
expect(projected.success).toBe(true);
expect(JSON.stringify(projected)).not.toMatch(/raw_url|token|secret|unregistered/);
});
it("validates traceparent without exposing invalid values", () => {
expect(
safeTraceparent(
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
),
).toBe("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
expect(safeTraceparent("Bearer secret")).toBeNull();
});
});
describe("best-effort telemetry adapter", () => {
it("bounds the queue using oldest-drop without blocking callers", () => {
const scheduled = [];
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.test/events",
maxQueue: 2,
schedule: (callback) => scheduled.push(callback),
fetcher: vi.fn(),
});
adapter.emit("api.request.failed", validAttributes);
adapter.emit("api.request.failed", validAttributes);
adapter.emit("api.request.failed", validAttributes);
expect(adapter.pendingCount()).toBe(2);
expect(adapter.droppedCount()).toBe(1);
expect(scheduled).toHaveLength(1);
});
it("degrades on sink failure without throwing or recursive events", async () => {
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.test/events",
schedule: () => {},
fetcher: async () => {
throw new Error("sink unavailable");
},
});
expect(() => adapter.emit("api.request.failed", validAttributes)).not.toThrow();
await expect(adapter.flush()).resolves.toBeUndefined();
expect(adapter.droppedCount()).toBe(1);
expect(adapter.pendingCount()).toBe(0);
});
it("performs no network or queue work when disabled", async () => {
const fetcher = vi.fn();
const adapter = createTelemetryAdapter({
enabled: false,
endpoint: "https://telemetry.test/events",
fetcher,
});
adapter.emit("api.request.failed", validAttributes);
await adapter.flush();
expect(fetcher).not.toHaveBeenCalled();
expect(adapter.pendingCount()).toBe(0);
});
});