feat: connect application input and output boundaries

This commit is contained in:
donghyeon-ka
2026-07-26 13:52:35 +09:00
parent 38ad69236b
commit 2dda17cf19
43 changed files with 697 additions and 215 deletions
+6
View File
@@ -29,6 +29,12 @@ module.exports = {
from: { path: "^src/adapters" }, from: { path: "^src/adapters" },
to: { path: "^src/(presentation|bootstrap)" }, to: { path: "^src/(presentation|bootstrap)" },
}, },
{
name: "concrete-adapters-compose-only-in-bootstrap",
severity: "error",
from: { path: "^src/(domain|application|presentation|contracts|sample)" },
to: { path: "^src/adapters" },
},
{ {
name: "no-circular-dependencies", name: "no-circular-dependencies",
severity: "error", severity: "error",
+3 -1
View File
@@ -76,7 +76,9 @@
{ "script": "check:types", "expect": "pass" }, { "script": "check:types", "expect": "pass" },
{ "script": "check:types:fixture", "expect": "fail" }, { "script": "check:types:fixture", "expect": "fail" },
{ "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" } { "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" }
], ],
"logPath": "artifacts/quality/check-types.txt", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
@@ -130,21 +130,27 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다
- render boundary와 async state surface - render boundary와 async state surface
- route registry와 lazy page - route registry와 lazy page
그러나 현재 실행 경로에는 다음 불일치가 있다. RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
1. `src/bootstrap/composition-root.js``application`을 만들지만 - `src/bootstrap/composition-root.js`만든 application을 production
`src/bootstrap/main.jsx`는 이를 사용하지 않고 `authSession`, `ApplicationProvider`가 실제 React tree에 주입한다.
`storage`, `telemetry`를 presentation에 직접 전달한다. - `createApplication`은 session, preference, diagnostics, runtime query의
2. `createApplication`이 input use case 대신 cache, storage, telemetry input API만 반환하며 storage, telemetry, release output port를 숨긴다.
output port를 그대로 노출한다. - bootstrap composition 결과는 raw output port를 반환하지 않고
3. `QueryClientProvider`는 존재하지만 실제 product route에서 application과 React infrastructure만 반환한다.
- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
application의 React/concrete adapter import는 negative fixture가 거절한다.
후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다.
1. `QueryClientProvider`는 존재하지만 실제 product route에서
`useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다. `useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다.
4. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, 2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. `errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다.
5. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API 3. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API
operation, Zod schema, mapper, domain model과 query key는 다른 경로에 operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다. 남는다.
6. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만
concrete HTTP client 구성에 전달되지 않는다. concrete HTTP client 구성에 전달되지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
+19 -1
View File
@@ -23,7 +23,12 @@ const layerPatterns = {
"react-dom", "react-dom",
"@tanstack/**", "@tanstack/**",
], ],
presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"], presentation: [
"**/adapters/**",
"**/bootstrap/**",
"**/application/ports/out/**",
"@tanstack/**",
],
adapters: ["**/presentation/**", "**/bootstrap/**"], adapters: ["**/presentation/**", "**/bootstrap/**"],
}; };
@@ -163,6 +168,12 @@ export default [
files: [`src/presentation/**/*.${sourceExtensions}`], files: [`src/presentation/**/*.${sourceExtensions}`],
rules: { rules: {
"no-restricted-imports": restrictedImports(layerPatterns.presentation), "no-restricted-imports": restrictedImports(layerPatterns.presentation),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
}, },
}, },
{ {
@@ -187,10 +198,17 @@ export default [
rules: { rules: {
"no-restricted-imports": restrictedImports([ "no-restricted-imports": restrictedImports([
"**/adapters/**", "**/adapters/**",
"**/application/ports/out/**",
"@tanstack/**", "@tanstack/**",
"react", "react",
"react-dom", "react-dom",
]), ]),
"no-restricted-globals": [
"error",
"fetch",
"localStorage",
"sessionStorage",
],
}, },
}, },
]; ];
+2
View File
@@ -22,6 +22,8 @@
"check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js", "check:types:fixture": "tsc --ignoreConfig --allowJs --checkJs --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.js",
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts", "check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
"check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "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: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:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
+44 -3
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, readdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
await mkdir("artifacts/quality", { recursive: true }); await mkdir("artifacts/quality", { recursive: true });
@@ -60,10 +60,51 @@ const forbidden = runPnpm(
], ],
); );
if (allowed.status !== 0 || forbidden.status === 0) { /** @param {string} directory @returns {Promise<string[]>} */
async function fixtureFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const target = `${directory}/${entry.name}`;
return entry.isDirectory()
? fixtureFiles(target)
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
? [target]
: [];
}),
);
return files.flat();
}
const forbiddenResults = await Promise.all(
(await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({
file,
result: runPnpm([
"exec",
"eslint",
file,
"--no-ignore",
"--max-warnings=0",
]),
})),
);
const acceptedForbidden = forbiddenResults.filter(
({ result }) => result.status === 0,
);
if (
allowed.status !== 0 ||
forbidden.status === 0 ||
acceptedForbidden.length > 0
) {
process.stderr.write(allowed.stderr || allowed.stdout); process.stderr.write(allowed.stderr || allowed.stdout);
process.stderr.write(forbidden.stderr || forbidden.stdout); process.stderr.write(forbidden.stderr || forbidden.stdout);
for (const { file } of acceptedForbidden) {
process.stderr.write(`Forbidden fixture was accepted: ${file}\n`);
}
process.exit(1); process.exit(1);
} }
process.stdout.write("Architecture fixtures: allowed PASS, forbidden rejected\n"); process.stdout.write(
`Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`,
);
+1 -1
View File
@@ -1,4 +1,4 @@
import { systemClock } from "../../application/ports/clock-port.js"; import { systemClock } from "../platform/system-clock.js";
import { getApiOperation } from "../../contracts/api-operations.js"; import { getApiOperation } from "../../contracts/api-operations.js";
import { import {
createFailure as failure, createFailure as failure,
+19
View File
@@ -0,0 +1,19 @@
/** @type {import("../../application/ports/clock-port.js").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);
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
},
});
-46
View File
@@ -1,46 +0,0 @@
/**
* 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 /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).query.execute(query, context);
}
/**
* @param {unknown} command
* @param {import("./ports/resource-ports.js").RequestContext} [context]
*/
function commandResources(command, context) {
return /** @type {NonNullable<typeof ports.resources>} */ (
ports.resources
).command.execute(command, context);
}
return Object.freeze({
resources: ports.resources
? Object.freeze({
query: queryResources,
command: commandResources,
})
: null,
cache: ports.cache,
storage: ports.storage,
telemetry: ports.telemetry,
});
}
+73
View File
@@ -0,0 +1,73 @@
import { normalizeColorSchemePreference } from "./policies/color-scheme.js";
import type {
ApplicationApi,
ColorSchemePreference,
RenderFailureReport,
} from "./ports/in/application-api.js";
import type { ApplicationOutputPorts } from "./ports/out/application-output-ports.js";
export type { ApplicationApi, ApplicationOutputPorts };
/**
* Builds the driving API consumed by inbound adapters. Concrete output ports
* remain inside these closures and are never returned to React.
*/
export function createApplication(
outputPorts: ApplicationOutputPorts,
): ApplicationApi {
const session = Object.freeze({
getSnapshot: () => outputPorts.session.getState(),
subscribe: (listener: () => void) =>
outputPorts.session.subscribe(listener),
beginSignIn: (returnTo?: string) =>
outputPorts.session.beginSignIn(returnTo),
signOut: () => outputPorts.session.signOut(),
recover: () => outputPorts.session.recover(),
});
const preferences = Object.freeze({
getColorScheme(): ColorSchemePreference {
const result = outputPorts.preferences.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result.ok ? result.value : undefined,
);
},
setColorScheme(preference: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(preference);
return outputPorts.preferences.write("COLOR_SCHEME", normalized);
},
});
const diagnostics = Object.freeze({
reportRenderFailure(report: RenderFailureReport) {
try {
outputPorts.diagnostics.emit("ui.render.failed", {
route_id: report.routeId,
build_id: report.buildId,
component_boundary: report.boundaryName,
});
} catch {
// Diagnostics are best-effort and cannot become an application failure.
}
},
});
const runtime = Object.freeze({
async getReleaseSummary() {
const release = await outputPorts.releaseInfo.getCurrent();
return Object.freeze({
buildId: release.buildId,
releaseId: release.releaseId,
configSchemaVersion: release.configSchemaVersion,
apiContractVersion: release.apiContractVersion,
});
},
});
return Object.freeze({
session,
preferences,
diagnostics,
runtime,
});
}
+16 -2
View File
@@ -10,10 +10,24 @@
* subscribe(listener: () => void): () => void, * subscribe(listener: () => void): () => void,
* beginSignIn(returnTo?: string): Promise<void>, * beginSignIn(returnTo?: string): Promise<void>,
* signOut(): Promise<void>, * signOut(): Promise<void>,
* recover(): Promise<"restored" | "no-session">
* }} SessionGateway
*/
/**
* Credential attachment is an HTTP-adapter collaboration, not an application
* input capability.
*
* @typedef {{
* attach(request: Request): Promise<Request>, * attach(request: Request): Promise<Request>,
* recover(): Promise<"restored" | "no-session">,
* onUnauthenticated(): void * onUnauthenticated(): void
* }} AuthSessionPort * }} CredentialAttacher
*/
/**
* External auth adapters implement both segregated capabilities.
*
* @typedef {SessionGateway & CredentialAttacher} AuthSessionPort
*/ */
export {}; export {};
+1 -22
View File
@@ -5,25 +5,4 @@
* }} ClockPort * }} ClockPort
*/ */
/** @type {ClockPort} */ export {};
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,41 @@
import type { SessionState } from "../auth-session-port.js";
import type { StoragePort } from "../storage-port.js";
export type { SessionState } from "../auth-session-port.js";
export type ColorSchemePreference = "system" | "light" | "dark";
export type RenderFailureReport = Readonly<{
routeId: string;
buildId: string;
boundaryName: "route" | "feature";
}>;
export type ReleaseSummary = Readonly<{
buildId: string;
releaseId: string;
configSchemaVersion: string;
apiContractVersion: string;
}>;
export type ApplicationApi = Readonly<{
session: Readonly<{
getSnapshot(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<"restored" | "no-session">;
}>;
preferences: Readonly<{
getColorScheme(): ColorSchemePreference;
setColorScheme(
preference: ColorSchemePreference,
): ReturnType<StoragePort["write"]>;
}>;
diagnostics: Readonly<{
reportRenderFailure(report: RenderFailureReport): void;
}>;
runtime: Readonly<{
getReleaseSummary(): Promise<ReleaseSummary>;
}>;
}>;
+7
View File
@@ -0,0 +1,7 @@
export type {
ApplicationApi,
ColorSchemePreference,
ReleaseSummary,
RenderFailureReport,
SessionState,
} from "./application-api.js";
@@ -0,0 +1,18 @@
import type { AuthSessionPort } from "../auth-session-port.js";
import type { ReleaseInfoPort } from "../release-info-port.js";
import type { StoragePort } from "../storage-port.js";
import type { TelemetryPort } from "../telemetry-port.js";
/**
* Capabilities required by application use cases. Implementations live in
* outbound adapters and are selected only by bootstrap.
*/
export type ApplicationOutputPorts = Readonly<{
session: Pick<
AuthSessionPort,
"getState" | "subscribe" | "beginSignIn" | "signOut" | "recover"
>;
preferences: StoragePort;
diagnostics: TelemetryPort;
releaseInfo: ReleaseInfoPort;
}>;
+17
View File
@@ -0,0 +1,17 @@
export type { ApplicationOutputPorts } from "./application-output-ports.js";
export type {
AuthSessionPort,
CredentialAttacher,
SessionGateway,
} from "../auth-session-port.js";
export type { ClockPort } from "../clock-port.js";
export type { QueryCachePort } from "../query-cache-port.js";
export type { ReleaseInfoPort } from "../release-info-port.js";
export type {
RequestContext,
ResourceCommandPort,
ResourceQueryPort,
Result,
} from "../resource-ports.js";
export type { StoragePort } from "../storage-port.js";
export type { TelemetryPort } from "../telemetry-port.js";
+5 -1
View File
@@ -1,11 +1,15 @@
/** /**
* @typedef {{ * @typedef {{
* getCurrent(): Promise<{ * getCurrent(): Promise<{
* schemaVersion?: number,
* appVersion?: string,
* buildId: string, * buildId: string,
* commitSha?: string,
* configSchemaVersion: string, * configSchemaVersion: string,
* apiContractVersion: string, * apiContractVersion: string,
* assetManifestHash: string, * assetManifestHash: string,
* releaseId: string * releaseId: string,
* builtAt?: string
* }> * }>
* }} ReleaseInfoPort * }} ReleaseInfoPort
*/ */
+15 -6
View File
@@ -6,27 +6,36 @@ import { createApplication } from "../application/create-application.js";
* *
* @template Config * @template Config
* @template Release * @template Release
* @template {Parameters<typeof createApplication>[0]} Ports * @template {Parameters<typeof createApplication>[0]} OutputPorts
* @template Infrastructure
* @param {{ * @param {{
* loadConfig(): Promise<Config>, * loadConfig(): Promise<Config>,
* loadRelease(config: Config): Promise<Release>, * loadRelease(config: Config): Promise<Release>,
* createAdapters(context: { * createAdapters(context: {
* config: Config, * config: Config,
* release: Release * release: Release
* }): Promise<Ports> * }): Promise<{
* outputPorts: OutputPorts,
* infrastructure: Infrastructure
* }>
* }} factories * }} factories
* @returns {Promise<Readonly<{ * @returns {Promise<Readonly<{
* config: Config, * config: Config,
* release: Release, * release: Release,
* ports: Ports, * infrastructure: Infrastructure,
* application: ReturnType<typeof createApplication> * application: ReturnType<typeof createApplication>
* }>>} * }>>}
*/ */
export async function createCompositionRoot(factories) { export async function createCompositionRoot(factories) {
const config = await factories.loadConfig(); const config = await factories.loadConfig();
const release = await factories.loadRelease(config); const release = await factories.loadRelease(config);
const ports = await factories.createAdapters({ config, release }); const adapters = await factories.createAdapters({ config, release });
const application = createApplication(ports); const application = createApplication(adapters.outputPorts);
return Object.freeze({ config, release, ports, application }); return Object.freeze({
config,
release,
infrastructure: adapters.infrastructure,
application,
});
} }
+3 -4
View File
@@ -6,22 +6,21 @@ import {
/** /**
* Applies the persisted public preference before React paints. * Applies the persisted public preference before React paints.
* *
* @param {import("../application/ports/storage-port.js").StoragePort} storage * @param {Pick<import("../application/ports/in/application-api.js").ApplicationApi["preferences"], "getColorScheme">} preferences
* @param {{ * @param {{
* documentElement?: HTMLElement, * documentElement?: HTMLElement,
* matchMedia?: (query: string) => MediaQueryList * matchMedia?: (query: string) => MediaQueryList
* }} [browser] * }} [browser]
*/ */
export function initializeColorScheme(storage, browser = {}) { export function initializeColorScheme(preferences, browser = {}) {
const documentElement = browser.documentElement ?? document.documentElement; const documentElement = browser.documentElement ?? document.documentElement;
const matchMedia = const matchMedia =
browser.matchMedia ?? browser.matchMedia ??
(typeof window.matchMedia === "function" (typeof window.matchMedia === "function"
? window.matchMedia.bind(window) ? window.matchMedia.bind(window)
: () => /** @type {MediaQueryList} */ ({ matches: false })); : () => /** @type {MediaQueryList} */ ({ matches: false }));
const stored = storage.read("COLOR_SCHEME");
const preference = normalizeColorSchemePreference( const preference = normalizeColorSchemePreference(
stored.ok ? stored.value : undefined, preferences.getColorScheme(),
); );
const resolved = resolveColorScheme( const resolved = resolveColorScheme(
preference, preference,
+3 -17
View File
@@ -1,13 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
import { AppRouter } from "../presentation/routes/app-router.jsx";
import { createRuntimeComposition } from "./create-runtime-composition.js"; import { createRuntimeComposition } from "./create-runtime-composition.js";
import { initializeColorScheme } from "./initialize-color-scheme.js"; import { initializeColorScheme } from "./initialize-color-scheme.js";
import { BootConfigError } from "./load-runtime-config.js"; import { BootConfigError } from "./load-runtime-config.js";
import { ReleaseManifestError } from "./load-release-manifest.js"; import { ReleaseManifestError } from "./load-release-manifest.js";
import { RuntimeApplication } from "./runtime-application.jsx";
import "../presentation/styles/theme.css"; import "../presentation/styles/theme.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@@ -21,20 +19,8 @@ const root = createRoot(rootElement);
async function boot() { async function boot() {
try { try {
const composition = await createRuntimeComposition(); const composition = await createRuntimeComposition();
initializeColorScheme(composition.ports.storage); initializeColorScheme(composition.application.preferences);
root.render( root.render(<RuntimeApplication composition={composition} />);
<StrictMode>
<QueryClientProvider client={composition.ports.queryClient}>
<AppRouter
authSession={composition.ports.authSession}
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
storage={composition.ports.storage}
telemetry={composition.ports.telemetry}
/>
</QueryClientProvider>
</StrictMode>,
);
} catch (error) { } catch (error) {
const safe = const safe =
error instanceof BootConfigError || error instanceof ReleaseManifestError error instanceof BootConfigError || error instanceof ReleaseManifestError
+9 -15
View File
@@ -3,9 +3,7 @@ import {
createExternalAuthSessionAdapter, createExternalAuthSessionAdapter,
createUnavailableSessionAdapter, createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js"; } from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import { import {
createQueryCacheAdapter,
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js";
@@ -61,7 +59,6 @@ export async function createRuntimeAdapters(context) {
? createExternalAuthSessionAdapter(externalOwner) ? createExternalAuthSessionAdapter(externalOwner)
: createUnavailableSessionAdapter(); : createUnavailableSessionAdapter();
const queryClient = createQueryClient(); const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const storage = createBrowserStorageAdapter({ const storage = createBrowserStorageAdapter({
localStorage: storageOrUndefined(host.localStorage), localStorage: storageOrUndefined(host.localStorage),
sessionStorage: storageOrUndefined(host.sessionStorage), sessionStorage: storageOrUndefined(host.sessionStorage),
@@ -71,11 +68,6 @@ export async function createRuntimeAdapters(context) {
endpoint: config.TELEMETRY_ENDPOINT, endpoint: config.TELEMETRY_ENDPOINT,
fetcher: context.fetcher, fetcher: context.fetcher,
}); });
const http = createHttpClient({
baseUrl: config.API_BASE_URL,
authSession,
fetcher: context.fetcher,
});
const releaseInfo = Object.freeze({ const releaseInfo = Object.freeze({
async getCurrent() { async getCurrent() {
return structuredClone(context.release); return structuredClone(context.release);
@@ -83,12 +75,14 @@ export async function createRuntimeAdapters(context) {
}); });
return Object.freeze({ return Object.freeze({
authSession, outputPorts: Object.freeze({
cache, session: authSession,
http, preferences: storage,
queryClient, diagnostics: telemetry,
releaseInfo, releaseInfo,
storage, }),
telemetry, infrastructure: Object.freeze({
queryClient,
}),
}); });
} }
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ApplicationProvider } from "../presentation/providers/application-provider.js";
import { AppRouter } from "../presentation/routes/app-router.jsx";
/**
* Production provider tree. Tests import this component so the validated
* composition is proven against the same provider order used by main.
*
* @param {{
* composition: Awaited<ReturnType<typeof import("./create-runtime-composition.js").createRuntimeComposition>>
* }} props
*/
export function RuntimeApplication({ composition }) {
return (
<StrictMode>
<QueryClientProvider client={composition.infrastructure.queryClient}>
<ApplicationProvider application={composition.application}>
<AppRouter
basename={composition.config.build.routerBasePath}
buildId={composition.release.buildId}
/>
</ApplicationProvider>
</QueryClientProvider>
</StrictMode>
);
}
@@ -6,7 +6,7 @@ import { Component } from "react";
* boundaryName: string, * boundaryName: string,
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, * onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode * fallback?: React.ReactNode
* }} RenderBoundaryProps * }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState * @typedef {{ hasError: boolean }} RenderBoundaryState
@@ -26,13 +26,14 @@ export class RenderErrorBoundary extends Component {
componentDidCatch() { componentDidCatch() {
try { try {
this.props.telemetry?.emit("ui.render.failed", { this.props.onRenderFailure?.({
route_id: this.props.routeId, routeId: this.props.routeId,
build_id: this.props.buildId, buildId: this.props.buildId,
component_boundary: this.props.boundaryName, boundaryName:
/** @type {"route" | "feature"} */ (this.props.boundaryName),
}); });
} catch { } catch {
// Telemetry must never recurse into another render failure. // Diagnostics must never recurse into another render failure.
} }
} }
+24
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js"; import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([ const READINESS_ITEMS = Object.freeze([
{ {
@@ -19,6 +21,23 @@ const READINESS_ITEMS = Object.freeze([
]); ]);
export default function HomePage() { export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState(
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
null
),
);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
return ( return (
<section className="ui-page"> <section className="ui-page">
<PageHeader <PageHeader
@@ -34,6 +53,11 @@ export default function HomePage() {
</article> </article>
))} ))}
</div> </div>
<p className="ui-runtime-summary" aria-live="polite">
{release
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
: "검증된 런타임 정보를 확인하고 있습니다."}
</p>
<section className="ui-panel starter-actions" aria-labelledby="starter-title"> <section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div> <div>
<h2 id="starter-title">준비된 화면 살펴보기</h2> <h2 id="starter-title">준비된 화면 살펴보기</h2>
@@ -0,0 +1,31 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.js";
const ApplicationContext = createContext<ApplicationApi | null>(null);
export function ApplicationProvider({
application,
children,
}: Readonly<{
application: ApplicationApi;
children: ReactNode;
}>) {
return (
<ApplicationContext.Provider value={application}>
{children}
</ApplicationContext.Provider>
);
}
export function useApplication(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
+15 -16
View File
@@ -1,11 +1,12 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { useApplication } from "./application-provider.js";
/** /**
* @typedef {{ * @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState, * sessionState: import("../../application/ports/in/application-api.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"], * beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"], * signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"] * recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"]
* }} SessionContextValue * }} SessionContextValue
*/ */
@@ -14,26 +15,24 @@ const SessionContext = createContext(
); );
/** /**
* @param {{ * @param {{ children: React.ReactNode }} props
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
*/ */
export function SessionProvider({ authSession, children }) { export function SessionProvider({ children }) {
const { session } = useApplication();
const sessionState = useSyncExternalStore( const sessionState = useSyncExternalStore(
authSession.subscribe, session.subscribe,
authSession.getState, session.getSnapshot,
authSession.getState, session.getSnapshot,
); );
const value = useMemo( const value = useMemo(
() => () =>
Object.freeze({ Object.freeze({
sessionState, sessionState,
beginSignIn: authSession.beginSignIn, beginSignIn: session.beginSignIn,
signOut: authSession.signOut, signOut: session.signOut,
recover: authSession.recover, recover: session.recover,
}), }),
[authSession, sessionState], [session, sessionState],
); );
return ( return (
+9 -13
View File
@@ -11,6 +11,7 @@ import {
normalizeColorSchemePreference, normalizeColorSchemePreference,
resolveColorScheme, resolveColorScheme,
} from "../../application/policies/color-scheme.js"; } from "../../application/policies/color-scheme.js";
import { useApplication } from "./application-provider.js";
/** /**
* @typedef {{ * @typedef {{
@@ -30,18 +31,13 @@ function systemPrefersDark() {
} }
/** /**
* @param {{ * @param {{ children: React.ReactNode }} props
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
*/ */
export function ThemeProvider({ storage, children }) { export function ThemeProvider({ children }) {
const [preference, updatePreference] = useState(() => { const { preferences } = useApplication();
const result = storage?.read("COLOR_SCHEME"); const [preference, updatePreference] = useState(
return normalizeColorSchemePreference( preferences.getColorScheme,
result?.ok ? result.value : undefined, );
);
});
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark); const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme); const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
@@ -70,10 +66,10 @@ export function ThemeProvider({ storage, children }) {
setPreference(next) { setPreference(next) {
const normalized = normalizeColorSchemePreference(next); const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized); updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized); preferences.setColorScheme(normalized);
}, },
}), }),
[preference, resolvedTheme, storage], [preference, preferences, resolvedTheme],
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+9 -21
View File
@@ -10,6 +10,7 @@ import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx"; import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx"; import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx"; import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx"; import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js"; import { decideRouteAccess } from "./navigation-policy.js";
@@ -54,16 +55,16 @@ function RouteFailureSurface() {
* @param {{ * @param {{
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode * children: React.ReactNode
* }} props * }} props
*/ */
function RouteSurface({ routeId, buildId, telemetry, children }) { function RouteSurface({ routeId, buildId, children }) {
const { diagnostics } = useApplication();
return ( return (
<RouteBoundary <RouteBoundary
routeId={routeId} routeId={routeId}
buildId={buildId} buildId={buildId}
telemetry={telemetry} onRenderFailure={diagnostics.reportRenderFailure}
fallback={<RouteFailureSurface />} fallback={<RouteFailureSurface />}
> >
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}> <Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
@@ -155,13 +156,12 @@ function ProtectedRoute({ routeId, children }) {
* @param {{ * @param {{
* routeId: string, * routeId: string,
* buildId: string, * buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode * children: React.ReactNode
* }} props * }} props
*/ */
function PublicRoute({ routeId, buildId, telemetry, children }) { function PublicRoute({ routeId, buildId, children }) {
return ( return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}> <RouteSurface routeId={routeId} buildId={buildId}>
{children} {children}
</RouteSurface> </RouteSurface>
); );
@@ -169,24 +169,18 @@ function PublicRoute({ routeId, buildId, telemetry, children }) {
/** /**
* @param {{ * @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string, * basename?: string,
* buildId?: string, * buildId?: string
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* }} props * }} props
*/ */
export function AppRouter({ export function AppRouter({
authSession,
basename = "/", basename = "/",
buildId = "local-build", buildId = "local-build",
storage,
telemetry,
}) { }) {
return ( return (
<BrowserRouter basename={basename}> <BrowserRouter basename={basename}>
<ThemeProvider storage={storage}> <ThemeProvider>
<SessionProvider authSession={authSession}> <SessionProvider>
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route <Route
@@ -195,7 +189,6 @@ export function AppRouter({
<PublicRoute <PublicRoute
routeId="APP_HOME" routeId="APP_HOME"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<HomePage /> <HomePage />
</PublicRoute> </PublicRoute>
@@ -207,7 +200,6 @@ export function AppRouter({
<PublicRoute <PublicRoute
routeId="EXAMPLES_UI" routeId="EXAMPLES_UI"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<UiGalleryPage /> <UiGalleryPage />
</PublicRoute> </PublicRoute>
@@ -219,7 +211,6 @@ export function AppRouter({
<PublicRoute <PublicRoute
routeId="EXAMPLES_STATES" routeId="EXAMPLES_STATES"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<StateGalleryPage /> <StateGalleryPage />
</PublicRoute> </PublicRoute>
@@ -231,7 +222,6 @@ export function AppRouter({
<PublicRoute <PublicRoute
routeId="EXAMPLES_AUTH" routeId="EXAMPLES_AUTH"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<AuthExamplePage /> <AuthExamplePage />
</PublicRoute> </PublicRoute>
@@ -243,7 +233,6 @@ export function AppRouter({
<RouteSurface <RouteSurface
routeId="SAMPLE_RESOURCE_LIST" routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST"> <ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage /> <SampleContractPage />
@@ -257,7 +246,6 @@ export function AppRouter({
<PublicRoute <PublicRoute
routeId="NOT_FOUND" routeId="NOT_FOUND"
buildId={buildId} buildId={buildId}
telemetry={telemetry}
> >
<NotFoundPage /> <NotFoundPage />
</PublicRoute> </PublicRoute>
+1 -1
View File
@@ -2,7 +2,7 @@ import { getRoute } from "../../contracts/routes.js";
/** /**
* @param {string} routeId * @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState * @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
*/ */
export function decideRouteAccess(routeId, sessionState) { export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId); const route = getRoute(routeId);
+6 -6
View File
@@ -17,22 +17,22 @@ function Defect() {
describe("render recovery boundaries", () => { describe("render recovery boundaries", () => {
it("catches programmer defects and emits best-effort safe telemetry", () => { it("catches programmer defects and emits best-effort safe telemetry", () => {
const telemetry = { emit: vi.fn() }; const onRenderFailure = vi.fn();
render( render(
<FeatureBoundary <FeatureBoundary
routeId="APP_HOME" routeId="APP_HOME"
buildId="build-a" buildId="build-a"
telemetry={telemetry} onRenderFailure={onRenderFailure}
> >
<Defect /> <Defect />
</FeatureBoundary>, </FeatureBoundary>,
); );
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure"); expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", { expect(onRenderFailure).toHaveBeenCalledWith({
route_id: "APP_HOME", routeId: "APP_HOME",
build_id: "build-a", buildId: "build-a",
component_boundary: "feature", boundaryName: "feature",
}); });
}); });
+17 -4
View File
@@ -8,12 +8,25 @@ import {
createAnonymousSessionAdapter, createAnonymousSessionAdapter,
createDemoSessionAdapter, createDemoSessionAdapter,
} from "../../src/adapters/auth/external-session-adapter.js"; } from "../../src/adapters/auth/external-session-adapter.js";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.js";
import { AppRouter } from "../../src/presentation/routes/app-router.jsx"; import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
import { createTestApplication } from "../helpers/create-test-application.js";
/**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
*/
function renderRouter(session) {
return render(
<ApplicationProvider application={createTestApplication({ session })}>
<AppRouter />
</ApplicationProvider>,
);
}
describe("application router", () => { describe("application router", () => {
it("renders the app shell and not-found route without an API request", async () => { it("renders the app shell and not-found route without an API request", async () => {
window.history.pushState({}, "", "/missing"); window.history.pushState({}, "", "/missing");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />); renderRouter(createAnonymousSessionAdapter());
expect( expect(
await screen.findByRole("heading", { await screen.findByRole("heading", {
@@ -27,7 +40,7 @@ describe("application router", () => {
it("navigates between registry-backed example routes", async () => { it("navigates between registry-backed example routes", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
window.history.pushState({}, "", "/"); window.history.pushState({}, "", "/");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />); renderRouter(createAnonymousSessionAdapter());
await user.click( await user.click(
await screen.findByRole("link", { name: "UI 구성요소" }), await screen.findByRole("link", { name: "UI 구성요소" }),
@@ -43,7 +56,7 @@ describe("application router", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const authSession = createDemoSessionAdapter(); const authSession = createDemoSessionAdapter();
window.history.pushState({}, "", "/sample/resources"); window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={authSession} />); renderRouter(authSession);
expect( expect(
await screen.findByRole("heading", { name: "세션이 필요합니다." }), await screen.findByRole("heading", { name: "세션이 필요합니다." }),
@@ -59,7 +72,7 @@ describe("application router", () => {
it("fails closed when the auth integration does not change state", async () => { it("fails closed when the auth integration does not change state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
window.history.pushState({}, "", "/sample/resources"); window.history.pushState({}, "", "/sample/resources");
render(<AppRouter authSession={createAnonymousSessionAdapter()} />); renderRouter(createAnonymousSessionAdapter());
await user.click( await user.click(
await screen.findByRole("button", { name: "로그인 시작" }), await screen.findByRole("button", { name: "로그인 시작" }),
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeComposition } from "../../src/bootstrap/create-runtime-composition.js";
import { RuntimeApplication } from "../../src/bootstrap/runtime-application.jsx";
const runtimeConfig = {
APP_ENV: "local",
API_BASE_URL: "http://localhost:8080",
REQUEST_TIMEOUT_MS: 10_000,
MAX_RETRY_ATTEMPTS: 2,
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
CONFIG_SCHEMA_VERSION: "1",
API_CONTRACT_VERSION: "1",
RELEASE_MANIFEST_URL: "/release-manifest.json",
BUILD_ID: "local-build",
RELEASE_ID: "local-release",
};
const releaseManifest = {
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "local-build",
commitSha: "local",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
};
describe("production runtime application tree", () => {
it("connects validated config and release through composition and ApplicationProvider", async () => {
const fetcher = vi.fn(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
return Response.json(
url.includes("release-manifest") ? releaseManifest : runtimeConfig,
);
});
const composition = await createRuntimeComposition({
fetcher,
host: {},
});
window.history.pushState({}, "", "/");
render(<RuntimeApplication composition={composition} />);
expect(
await screen.findByRole("heading", {
name: "Clean Architecture Frontend",
}),
).toBeVisible();
expect(
await screen.findByText("빌드 local-build · 릴리스 local-release"),
).toBeVisible();
expect(fetcher).toHaveBeenCalledTimes(2);
expect(composition).not.toHaveProperty("ports");
});
});
@@ -0,0 +1,3 @@
import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.js";
export const bootstrapFactory = createRuntimeAdapters;
@@ -0,0 +1,3 @@
import { createHttpClient } from "../../../../src/adapters/http/client.js";
export const leakedHttpFactory = createHttpClient;
@@ -0,0 +1,4 @@
export function DirectFetchPage() {
void fetch("/api/forbidden");
return <p>forbidden</p>;
}
@@ -0,0 +1,5 @@
import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.js";
export function OutputPortLeak(_props: ApplicationOutputPorts) {
return <p>forbidden</p>;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ApplicationApi } from "../../../src/application/create-application.js";
export const incompleteApplication: ApplicationApi = {
session: {} as ApplicationApi["session"],
};
@@ -0,0 +1,7 @@
import type { ApplicationOutputPorts } from "../../../src/application/create-application.js";
export const invalidOutputPorts: ApplicationOutputPorts = {
session: {
getState: () => "signed-in",
},
};
+35
View File
@@ -0,0 +1,35 @@
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createApplication } from "../../src/application/create-application.js";
/**
* @param {{
* session?: import("../../src/application/ports/auth-session-port.js").AuthSessionPort,
* preferences?: import("../../src/application/ports/storage-port.js").StoragePort,
* diagnostics?: import("../../src/application/ports/telemetry-port.js").TelemetryPort,
* releaseInfo?: import("../../src/application/ports/release-info-port.js").ReleaseInfoPort
* }} [overrides]
*/
export function createTestApplication(overrides = {}) {
return createApplication({
session: overrides.session ?? createAnonymousSessionAdapter(),
preferences:
overrides.preferences ??
{
read: () => ({ ok: /** @type {const} */ (true), value: "system" }),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
},
diagnostics: overrides.diagnostics ?? { emit: () => {} },
releaseInfo:
overrides.releaseInfo ??
{
getCurrent: async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
}),
},
});
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import {
createApplication,
type ApplicationOutputPorts,
} from "../../src/application/create-application.js";
import { createTestApplication } from "../helpers/create-test-application.js";
describe("application input/output boundary", () => {
it("exposes intent-oriented input APIs without leaking output ports", async () => {
const application = createTestApplication();
expect(Object.keys(application)).toEqual([
"session",
"preferences",
"diagnostics",
"runtime",
]);
expect(application).not.toHaveProperty("storage");
expect(application).not.toHaveProperty("telemetry");
expect(application).not.toHaveProperty("releaseInfo");
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
});
});
it("uses fake output ports for preference, session, and safe diagnostics flows", () => {
const write = vi.fn(() => ({ ok: true as const }));
const emit = vi.fn();
const ports = {
session: {
getState: () => "authenticated" as const,
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
recover: async () => "restored" as const,
},
preferences: {
read: () => ({ ok: true as const, value: "dark" }),
write,
remove: () => ({ ok: true as const }),
},
diagnostics: { emit },
releaseInfo: {
getCurrent: async () => ({
buildId: "build-a",
releaseId: "release-a",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "hash-a",
}),
},
} satisfies ApplicationOutputPorts;
const application = createApplication(ports);
expect(application.session.getSnapshot()).toBe("authenticated");
expect(application.preferences.getColorScheme()).toBe("dark");
expect(application.preferences.setColorScheme("light")).toEqual({ ok: true });
expect(write).toHaveBeenCalledWith("COLOR_SCHEME", "light");
application.diagnostics.reportRenderFailure({
routeId: "APP_HOME",
buildId: "build-a",
boundaryName: "route",
});
expect(emit).toHaveBeenCalledWith("ui.render.failed", {
route_id: "APP_HOME",
build_id: "build-a",
component_boundary: "route",
});
});
it("does not let a failing diagnostics output escape into presentation", () => {
const application = createTestApplication({
diagnostics: {
emit() {
throw new Error("sink details");
},
},
});
expect(() =>
application.diagnostics.reportRenderFailure({
routeId: "APP_HOME",
buildId: "build-a",
boundaryName: "feature",
}),
).not.toThrow();
});
});
+3 -11
View File
@@ -21,17 +21,9 @@ describe("color scheme policy", () => {
}); });
it("applies a persisted preference before application paint", () => { it("applies a persisted preference before application paint", () => {
const storage = const result = initializeColorScheme({
/** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({ getColorScheme: () => "system",
read: () => ({ }, {
ok: /** @type {const} */ (true),
value: "system",
}),
write: () => ({ ok: /** @type {const} */ (true) }),
remove: () => ({ ok: /** @type {const} */ (true) }),
});
const result = initializeColorScheme(storage, {
documentElement: document.documentElement, documentElement: document.documentElement,
matchMedia: () => matchMedia: () =>
/** @type {MediaQueryList} */ ({ matches: true }), /** @type {MediaQueryList} */ ({ matches: true }),
+6 -7
View File
@@ -33,14 +33,13 @@ describe("runtime adapter composition", () => {
host: {}, host: {},
}); });
expect(adapters.authSession.getState()).toBe("unauthenticated"); expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
expect(adapters.cache.read(["missing"])).toEqual({ await expect(adapters.outputPorts.releaseInfo.getCurrent()).resolves.toMatchObject({
ok: true,
value: undefined,
});
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
releaseId: "release-a", releaseId: "release-a",
}); });
expect(adapters.infrastructure.queryClient).toBeDefined();
expect(adapters).not.toHaveProperty("http");
expect(adapters).not.toHaveProperty("storage");
}); });
it("fails closed when an external auth owner was not installed", async () => { it("fails closed when an external auth owner was not installed", async () => {
@@ -52,6 +51,6 @@ describe("runtime adapter composition", () => {
release, release,
host: {}, host: {},
}); });
expect(adapters.authSession.getState()).toBe("integration-failed"); expect(adapters.outputPorts.session.getState()).toBe("integration-failed");
}); });
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { systemClock } from "../../src/application/ports/clock-port.js"; import { systemClock } from "../../src/adapters/platform/system-clock.js";
describe("systemClock", () => { describe("systemClock", () => {
it("resolves after the requested duration", async () => { it("resolves after the requested duration", async () => {