diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index a1e20df..5197aa3 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -29,6 +29,12 @@ module.exports = { from: { path: "^src/adapters" }, 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", severity: "error", diff --git a/config/ci/gates.json b/config/ci/gates.json index 7d8d4ed..3b4ecb5 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -76,7 +76,9 @@ { "script": "check:types", "expect": "pass" }, { "script": "check:types:fixture", "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", "evidence": ["artifacts/quality/check-types.txt"], diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 11c0c8d..068bb7b 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -130,21 +130,27 @@ bootstrap은 page별 orchestration이나 업무 규칙을 소유하지 않는다 - render boundary와 async state surface - route registry와 lazy page -그러나 현재 실행 경로에는 다음 불일치가 있다. +RP-02 구현으로 다음 경계는 실행 경로에 연결됐다. -1. `src/bootstrap/composition-root.js`가 `application`을 만들지만 - `src/bootstrap/main.jsx`는 이를 사용하지 않고 `authSession`, - `storage`, `telemetry`를 presentation에 직접 전달한다. -2. `createApplication`이 input use case 대신 cache, storage, telemetry - output port를 그대로 노출한다. -3. `QueryClientProvider`는 존재하지만 실제 product route에서 +- `src/bootstrap/composition-root.js`가 만든 application을 production + `ApplicationProvider`가 실제 React tree에 주입한다. +- `createApplication`은 session, preference, diagnostics, runtime query의 + input API만 반환하며 storage, telemetry, release output port를 숨긴다. +- bootstrap composition 결과는 raw output port를 반환하지 않고 + 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가 없다. -4. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, +2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, `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는 다른 경로에 남는다. -6. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 +4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 concrete HTTP client 구성에 전달되지 않는다. 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 diff --git a/eslint.config.js b/eslint.config.js index f3e3d75..06a35f4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,7 +23,12 @@ const layerPatterns = { "react-dom", "@tanstack/**", ], - presentation: ["**/adapters/**", "**/bootstrap/**", "@tanstack/**"], + presentation: [ + "**/adapters/**", + "**/bootstrap/**", + "**/application/ports/out/**", + "@tanstack/**", + ], adapters: ["**/presentation/**", "**/bootstrap/**"], }; @@ -163,6 +168,12 @@ export default [ files: [`src/presentation/**/*.${sourceExtensions}`], rules: { "no-restricted-imports": restrictedImports(layerPatterns.presentation), + "no-restricted-globals": [ + "error", + "fetch", + "localStorage", + "sessionStorage", + ], }, }, { @@ -187,10 +198,17 @@ export default [ rules: { "no-restricted-imports": restrictedImports([ "**/adapters/**", + "**/application/ports/out/**", "@tanstack/**", "react", "react-dom", ]), + "no-restricted-globals": [ + "error", + "fetch", + "localStorage", + "sessionStorage", + ], }, }, ]; diff --git a/package.json b/package.json index 73d6348..00d5bbf 100644 --- a/package.json +++ b/package.json @@ -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: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: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: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", diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index fda21c6..fba5973 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -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"; 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} */ +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(forbidden.stderr || forbidden.stdout); + for (const { file } of acceptedForbidden) { + process.stderr.write(`Forbidden fixture was accepted: ${file}\n`); + } 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`, +); diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 5ff03bb..094b2ca 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -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 { createFailure as failure, diff --git a/src/adapters/platform/system-clock.js b/src/adapters/platform/system-clock.js new file mode 100644 index 0000000..62f45cd --- /dev/null +++ b/src/adapters/platform/system-clock.js @@ -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 }); + }); + }, +}); diff --git a/src/application/create-application.js b/src/application/create-application.js deleted file mode 100644 index 8d2b633..0000000 --- a/src/application/create-application.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Application facade factory. Concrete dependencies are supplied by bootstrap. - * - * @param {{ - * resources?: { - * query: import("./ports/resource-ports.js").ResourceQueryPort, - * command: import("./ports/resource-ports.js").ResourceCommandPort - * }, - * 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} */ ( - ports.resources - ).query.execute(query, context); - } - - /** - * @param {unknown} command - * @param {import("./ports/resource-ports.js").RequestContext} [context] - */ - function commandResources(command, context) { - return /** @type {NonNullable} */ ( - 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, - }); -} diff --git a/src/application/create-application.ts b/src/application/create-application.ts new file mode 100644 index 0000000..d3910bd --- /dev/null +++ b/src/application/create-application.ts @@ -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, + }); +} diff --git a/src/application/ports/auth-session-port.js b/src/application/ports/auth-session-port.js index 645f349..14931cf 100644 --- a/src/application/ports/auth-session-port.js +++ b/src/application/ports/auth-session-port.js @@ -10,10 +10,24 @@ * subscribe(listener: () => void): () => void, * beginSignIn(returnTo?: string): Promise, * signOut(): Promise, + * recover(): Promise<"restored" | "no-session"> + * }} SessionGateway + */ + +/** + * Credential attachment is an HTTP-adapter collaboration, not an application + * input capability. + * + * @typedef {{ * attach(request: Request): Promise, - * recover(): Promise<"restored" | "no-session">, * onUnauthenticated(): void - * }} AuthSessionPort + * }} CredentialAttacher + */ + +/** + * External auth adapters implement both segregated capabilities. + * + * @typedef {SessionGateway & CredentialAttacher} AuthSessionPort */ export {}; diff --git a/src/application/ports/clock-port.js b/src/application/ports/clock-port.js index 353d5a3..12b6b1b 100644 --- a/src/application/ports/clock-port.js +++ b/src/application/ports/clock-port.js @@ -5,25 +5,4 @@ * }} ClockPort */ -/** @type {ClockPort} */ -export const systemClock = Object.freeze({ - now: () => Date.now(), - sleep(milliseconds, signal) { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason); - return; - } - - const timer = setTimeout(resolve, milliseconds); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - reject(signal.reason); - }, - { once: true }, - ); - }); - }, -}); +export {}; diff --git a/src/application/ports/in/application-api.ts b/src/application/ports/in/application-api.ts new file mode 100644 index 0000000..0d7e108 --- /dev/null +++ b/src/application/ports/in/application-api.ts @@ -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; + signOut(): Promise; + recover(): Promise<"restored" | "no-session">; + }>; + preferences: Readonly<{ + getColorScheme(): ColorSchemePreference; + setColorScheme( + preference: ColorSchemePreference, + ): ReturnType; + }>; + diagnostics: Readonly<{ + reportRenderFailure(report: RenderFailureReport): void; + }>; + runtime: Readonly<{ + getReleaseSummary(): Promise; + }>; +}>; diff --git a/src/application/ports/in/index.ts b/src/application/ports/in/index.ts new file mode 100644 index 0000000..5c5028c --- /dev/null +++ b/src/application/ports/in/index.ts @@ -0,0 +1,7 @@ +export type { + ApplicationApi, + ColorSchemePreference, + ReleaseSummary, + RenderFailureReport, + SessionState, +} from "./application-api.js"; diff --git a/src/application/ports/out/application-output-ports.ts b/src/application/ports/out/application-output-ports.ts new file mode 100644 index 0000000..85b44bf --- /dev/null +++ b/src/application/ports/out/application-output-ports.ts @@ -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; +}>; diff --git a/src/application/ports/out/index.ts b/src/application/ports/out/index.ts new file mode 100644 index 0000000..9b364cd --- /dev/null +++ b/src/application/ports/out/index.ts @@ -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"; diff --git a/src/application/ports/release-info-port.js b/src/application/ports/release-info-port.js index b34b41a..cb291bd 100644 --- a/src/application/ports/release-info-port.js +++ b/src/application/ports/release-info-port.js @@ -1,11 +1,15 @@ /** * @typedef {{ * getCurrent(): Promise<{ + * schemaVersion?: number, + * appVersion?: string, * buildId: string, + * commitSha?: string, * configSchemaVersion: string, * apiContractVersion: string, * assetManifestHash: string, - * releaseId: string + * releaseId: string, + * builtAt?: string * }> * }} ReleaseInfoPort */ diff --git a/src/bootstrap/composition-root.js b/src/bootstrap/composition-root.js index 45e130a..c6c58a2 100644 --- a/src/bootstrap/composition-root.js +++ b/src/bootstrap/composition-root.js @@ -6,27 +6,36 @@ import { createApplication } from "../application/create-application.js"; * * @template Config * @template Release - * @template {Parameters[0]} Ports + * @template {Parameters[0]} OutputPorts + * @template Infrastructure * @param {{ * loadConfig(): Promise, * loadRelease(config: Config): Promise, * createAdapters(context: { * config: Config, * release: Release - * }): Promise + * }): Promise<{ + * outputPorts: OutputPorts, + * infrastructure: Infrastructure + * }> * }} factories * @returns {Promise * }>>} */ export async function createCompositionRoot(factories) { const config = await factories.loadConfig(); const release = await factories.loadRelease(config); - const ports = await factories.createAdapters({ config, release }); - const application = createApplication(ports); + const adapters = await factories.createAdapters({ config, release }); + const application = createApplication(adapters.outputPorts); - return Object.freeze({ config, release, ports, application }); + return Object.freeze({ + config, + release, + infrastructure: adapters.infrastructure, + application, + }); } diff --git a/src/bootstrap/initialize-color-scheme.js b/src/bootstrap/initialize-color-scheme.js index 3492cd6..fd29180 100644 --- a/src/bootstrap/initialize-color-scheme.js +++ b/src/bootstrap/initialize-color-scheme.js @@ -6,22 +6,21 @@ import { /** * Applies the persisted public preference before React paints. * - * @param {import("../application/ports/storage-port.js").StoragePort} storage + * @param {Pick} preferences * @param {{ * documentElement?: HTMLElement, * matchMedia?: (query: string) => MediaQueryList * }} [browser] */ -export function initializeColorScheme(storage, browser = {}) { +export function initializeColorScheme(preferences, browser = {}) { const documentElement = browser.documentElement ?? document.documentElement; const matchMedia = browser.matchMedia ?? (typeof window.matchMedia === "function" ? window.matchMedia.bind(window) : () => /** @type {MediaQueryList} */ ({ matches: false })); - const stored = storage.read("COLOR_SCHEME"); const preference = normalizeColorSchemePreference( - stored.ok ? stored.value : undefined, + preferences.getColorScheme(), ); const resolved = resolveColorScheme( preference, diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index c6ee9bb..c7d646a 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,13 +1,11 @@ -import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { QueryClientProvider } from "@tanstack/react-query"; 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 { initializeColorScheme } from "./initialize-color-scheme.js"; import { BootConfigError } from "./load-runtime-config.js"; import { ReleaseManifestError } from "./load-release-manifest.js"; +import { RuntimeApplication } from "./runtime-application.jsx"; import "../presentation/styles/theme.css"; const rootElement = document.getElementById("root"); @@ -21,20 +19,8 @@ const root = createRoot(rootElement); async function boot() { try { const composition = await createRuntimeComposition(); - initializeColorScheme(composition.ports.storage); - root.render( - - - - - , - ); + initializeColorScheme(composition.application.preferences); + root.render(); } catch (error) { const safe = error instanceof BootConfigError || error instanceof ReleaseManifestError diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js index d5b6162..b4b0cc7 100644 --- a/src/bootstrap/runtime-adapters.js +++ b/src/bootstrap/runtime-adapters.js @@ -3,9 +3,7 @@ import { createExternalAuthSessionAdapter, createUnavailableSessionAdapter, } from "../adapters/auth/external-session-adapter.js"; -import { createHttpClient } from "../adapters/http/client.js"; import { - createQueryCacheAdapter, createQueryClient, } from "../adapters/query-cache/tanstack-query-cache.js"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; @@ -61,7 +59,6 @@ export async function createRuntimeAdapters(context) { ? createExternalAuthSessionAdapter(externalOwner) : createUnavailableSessionAdapter(); const queryClient = createQueryClient(); - const cache = createQueryCacheAdapter(queryClient); const storage = createBrowserStorageAdapter({ localStorage: storageOrUndefined(host.localStorage), sessionStorage: storageOrUndefined(host.sessionStorage), @@ -71,11 +68,6 @@ export async function createRuntimeAdapters(context) { endpoint: config.TELEMETRY_ENDPOINT, fetcher: context.fetcher, }); - const http = createHttpClient({ - baseUrl: config.API_BASE_URL, - authSession, - fetcher: context.fetcher, - }); const releaseInfo = Object.freeze({ async getCurrent() { return structuredClone(context.release); @@ -83,12 +75,14 @@ export async function createRuntimeAdapters(context) { }); return Object.freeze({ - authSession, - cache, - http, - queryClient, - releaseInfo, - storage, - telemetry, + outputPorts: Object.freeze({ + session: authSession, + preferences: storage, + diagnostics: telemetry, + releaseInfo, + }), + infrastructure: Object.freeze({ + queryClient, + }), }); } diff --git a/src/bootstrap/runtime-application.jsx b/src/bootstrap/runtime-application.jsx new file mode 100644 index 0000000..20560b8 --- /dev/null +++ b/src/bootstrap/runtime-application.jsx @@ -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> + * }} props + */ +export function RuntimeApplication({ composition }) { + return ( + + + + + + + + ); +} diff --git a/src/presentation/boundaries/render-error-boundary.jsx b/src/presentation/boundaries/render-error-boundary.jsx index de3872d..c10ecb8 100644 --- a/src/presentation/boundaries/render-error-boundary.jsx +++ b/src/presentation/boundaries/render-error-boundary.jsx @@ -6,7 +6,7 @@ import { Component } from "react"; * boundaryName: string, * routeId: 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 * }} RenderBoundaryProps * @typedef {{ hasError: boolean }} RenderBoundaryState @@ -26,13 +26,14 @@ export class RenderErrorBoundary extends Component { componentDidCatch() { try { - this.props.telemetry?.emit("ui.render.failed", { - route_id: this.props.routeId, - build_id: this.props.buildId, - component_boundary: this.props.boundaryName, + this.props.onRenderFailure?.({ + routeId: this.props.routeId, + buildId: this.props.buildId, + boundaryName: + /** @type {"route" | "feature"} */ (this.props.boundaryName), }); } catch { - // Telemetry must never recurse into another render failure. + // Diagnostics must never recurse into another render failure. } } diff --git a/src/presentation/pages/home-page.jsx b/src/presentation/pages/home-page.jsx index f7c00a8..d13fefb 100644 --- a/src/presentation/pages/home-page.jsx +++ b/src/presentation/pages/home-page.jsx @@ -1,7 +1,9 @@ +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { routePath } from "../../contracts/routes.js"; import { PageHeader } from "../components/page-header.jsx"; +import { useApplication } from "../providers/application-provider.js"; const READINESS_ITEMS = Object.freeze([ { @@ -19,6 +21,23 @@ const READINESS_ITEMS = Object.freeze([ ]); export default function HomePage() { + const { runtime } = useApplication(); + const [release, setRelease] = useState( + /** @type {Awaited> | null} */ ( + null + ), + ); + + useEffect(() => { + let active = true; + void runtime.getReleaseSummary().then((summary) => { + if (active) setRelease(summary); + }); + return () => { + active = false; + }; + }, [runtime]); + return (
))} +

+ {release + ? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}` + : "검증된 런타임 정보를 확인하고 있습니다."} +

준비된 화면 살펴보기

diff --git a/src/presentation/providers/application-provider.tsx b/src/presentation/providers/application-provider.tsx new file mode 100644 index 0000000..6aee2e6 --- /dev/null +++ b/src/presentation/providers/application-provider.tsx @@ -0,0 +1,31 @@ +import { + createContext, + type ReactNode, + useContext, +} from "react"; + +import type { ApplicationApi } from "../../application/create-application.js"; + +const ApplicationContext = createContext(null); + +export function ApplicationProvider({ + application, + children, +}: Readonly<{ + application: ApplicationApi; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +export function useApplication(): ApplicationApi { + const application = useContext(ApplicationContext); + if (!application) { + throw new Error("ApplicationProvider is required"); + } + return application; +} diff --git a/src/presentation/providers/session-provider.jsx b/src/presentation/providers/session-provider.jsx index 770caca..6e4c867 100644 --- a/src/presentation/providers/session-provider.jsx +++ b/src/presentation/providers/session-provider.jsx @@ -1,11 +1,12 @@ import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; +import { useApplication } from "./application-provider.js"; /** * @typedef {{ - * sessionState: import("../../application/ports/auth-session-port.js").SessionState, - * beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"], - * signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"], - * recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"] + * sessionState: import("../../application/ports/in/application-api.js").SessionState, + * beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"], + * signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"], + * recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"] * }} SessionContextValue */ @@ -14,26 +15,24 @@ const SessionContext = createContext( ); /** - * @param {{ - * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, - * children: React.ReactNode - * }} props + * @param {{ children: React.ReactNode }} props */ -export function SessionProvider({ authSession, children }) { +export function SessionProvider({ children }) { + const { session } = useApplication(); const sessionState = useSyncExternalStore( - authSession.subscribe, - authSession.getState, - authSession.getState, + session.subscribe, + session.getSnapshot, + session.getSnapshot, ); const value = useMemo( () => Object.freeze({ sessionState, - beginSignIn: authSession.beginSignIn, - signOut: authSession.signOut, - recover: authSession.recover, + beginSignIn: session.beginSignIn, + signOut: session.signOut, + recover: session.recover, }), - [authSession, sessionState], + [session, sessionState], ); return ( diff --git a/src/presentation/providers/theme-provider.jsx b/src/presentation/providers/theme-provider.jsx index eb181f7..860ec87 100644 --- a/src/presentation/providers/theme-provider.jsx +++ b/src/presentation/providers/theme-provider.jsx @@ -11,6 +11,7 @@ import { normalizeColorSchemePreference, resolveColorScheme, } from "../../application/policies/color-scheme.js"; +import { useApplication } from "./application-provider.js"; /** * @typedef {{ @@ -30,18 +31,13 @@ function systemPrefersDark() { } /** - * @param {{ - * storage?: import("../../application/ports/storage-port.js").StoragePort, - * children: React.ReactNode - * }} props + * @param {{ children: React.ReactNode }} props */ -export function ThemeProvider({ storage, children }) { - const [preference, updatePreference] = useState(() => { - const result = storage?.read("COLOR_SCHEME"); - return normalizeColorSchemePreference( - result?.ok ? result.value : undefined, - ); - }); +export function ThemeProvider({ children }) { + const { preferences } = useApplication(); + const [preference, updatePreference] = useState( + preferences.getColorScheme, + ); const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark); const resolvedTheme = resolveColorScheme(preference, darkSystemTheme); @@ -70,10 +66,10 @@ export function ThemeProvider({ storage, children }) { setPreference(next) { const normalized = normalizeColorSchemePreference(next); updatePreference(normalized); - storage?.write("COLOR_SCHEME", normalized); + preferences.setColorScheme(normalized); }, }), - [preference, resolvedTheme, storage], + [preference, preferences, resolvedTheme], ); return {children}; diff --git a/src/presentation/routes/app-router.jsx b/src/presentation/routes/app-router.jsx index d7727a9..8aee8fa 100644 --- a/src/presentation/routes/app-router.jsx +++ b/src/presentation/routes/app-router.jsx @@ -10,6 +10,7 @@ import { getRoute, routePath } from "../../contracts/routes.js"; import { RouteBoundary } from "../boundaries/render-error-boundary.jsx"; import { AppShell } from "../layouts/app-shell.jsx"; import { PageHeader } from "../components/page-header.jsx"; +import { useApplication } from "../providers/application-provider.js"; import { SessionProvider, useSession } from "../providers/session-provider.jsx"; import { ThemeProvider } from "../providers/theme-provider.jsx"; import { decideRouteAccess } from "./navigation-policy.js"; @@ -54,16 +55,16 @@ function RouteFailureSurface() { * @param {{ * routeId: string, * buildId: string, - * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, * children: React.ReactNode * }} props */ -function RouteSurface({ routeId, buildId, telemetry, children }) { +function RouteSurface({ routeId, buildId, children }) { + const { diagnostics } = useApplication(); return ( } > }> @@ -155,13 +156,12 @@ function ProtectedRoute({ routeId, children }) { * @param {{ * routeId: string, * buildId: string, - * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, * children: React.ReactNode * }} props */ -function PublicRoute({ routeId, buildId, telemetry, children }) { +function PublicRoute({ routeId, buildId, children }) { return ( - + {children} ); @@ -169,24 +169,18 @@ function PublicRoute({ routeId, buildId, telemetry, children }) { /** * @param {{ - * authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort, * basename?: string, - * buildId?: string, - * storage?: import("../../application/ports/storage-port.js").StoragePort, - * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort + * buildId?: string * }} props */ export function AppRouter({ - authSession, basename = "/", buildId = "local-build", - storage, - telemetry, }) { return ( - - + + }> @@ -207,7 +200,6 @@ export function AppRouter({ @@ -219,7 +211,6 @@ export function AppRouter({ @@ -231,7 +222,6 @@ export function AppRouter({ @@ -243,7 +233,6 @@ export function AppRouter({ @@ -257,7 +246,6 @@ export function AppRouter({ diff --git a/src/presentation/routes/navigation-policy.js b/src/presentation/routes/navigation-policy.js index 9fa5133..0dd3468 100644 --- a/src/presentation/routes/navigation-policy.js +++ b/src/presentation/routes/navigation-policy.js @@ -2,7 +2,7 @@ import { getRoute } from "../../contracts/routes.js"; /** * @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) { const route = getRoute(routeId); diff --git a/tests/component/render-boundary.test.jsx b/tests/component/render-boundary.test.jsx index a920c16..f605677 100644 --- a/tests/component/render-boundary.test.jsx +++ b/tests/component/render-boundary.test.jsx @@ -17,22 +17,22 @@ function Defect() { describe("render recovery boundaries", () => { it("catches programmer defects and emits best-effort safe telemetry", () => { - const telemetry = { emit: vi.fn() }; + const onRenderFailure = vi.fn(); render( , ); 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", + expect(onRenderFailure).toHaveBeenCalledWith({ + routeId: "APP_HOME", + buildId: "build-a", + boundaryName: "feature", }); }); diff --git a/tests/component/router.test.jsx b/tests/component/router.test.jsx index d9fdfc0..69c06f0 100644 --- a/tests/component/router.test.jsx +++ b/tests/component/router.test.jsx @@ -8,12 +8,25 @@ import { createAnonymousSessionAdapter, createDemoSessionAdapter, } 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 { createTestApplication } from "../helpers/create-test-application.js"; + +/** + * @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session + */ +function renderRouter(session) { + return render( + + + , + ); +} describe("application router", () => { it("renders the app shell and not-found route without an API request", async () => { window.history.pushState({}, "", "/missing"); - render(); + renderRouter(createAnonymousSessionAdapter()); expect( await screen.findByRole("heading", { @@ -27,7 +40,7 @@ describe("application router", () => { it("navigates between registry-backed example routes", async () => { const user = userEvent.setup(); window.history.pushState({}, "", "/"); - render(); + renderRouter(createAnonymousSessionAdapter()); await user.click( await screen.findByRole("link", { name: "UI 구성요소" }), @@ -43,7 +56,7 @@ describe("application router", () => { const user = userEvent.setup(); const authSession = createDemoSessionAdapter(); window.history.pushState({}, "", "/sample/resources"); - render(); + renderRouter(authSession); expect( await screen.findByRole("heading", { name: "세션이 필요합니다." }), @@ -59,7 +72,7 @@ describe("application router", () => { it("fails closed when the auth integration does not change state", async () => { const user = userEvent.setup(); window.history.pushState({}, "", "/sample/resources"); - render(); + renderRouter(createAnonymousSessionAdapter()); await user.click( await screen.findByRole("button", { name: "로그인 시작" }), diff --git a/tests/component/runtime-application.test.jsx b/tests/component/runtime-application.test.jsx new file mode 100644 index 0000000..0a2c94e --- /dev/null +++ b/tests/component/runtime-application.test.jsx @@ -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(); + + 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"); + }); +}); diff --git a/tests/fixtures/architecture/allowed/bootstrap-composes-adapter.ts b/tests/fixtures/architecture/allowed/bootstrap-composes-adapter.ts new file mode 100644 index 0000000..d5038c0 --- /dev/null +++ b/tests/fixtures/architecture/allowed/bootstrap-composes-adapter.ts @@ -0,0 +1,3 @@ +import { createRuntimeAdapters } from "../../../../src/bootstrap/runtime-adapters.js"; + +export const bootstrapFactory = createRuntimeAdapters; diff --git a/tests/fixtures/architecture/forbidden/application-imports-adapter.ts b/tests/fixtures/architecture/forbidden/application-imports-adapter.ts new file mode 100644 index 0000000..676ff88 --- /dev/null +++ b/tests/fixtures/architecture/forbidden/application-imports-adapter.ts @@ -0,0 +1,3 @@ +import { createHttpClient } from "../../../../src/adapters/http/client.js"; + +export const leakedHttpFactory = createHttpClient; diff --git a/tests/fixtures/architecture/forbidden/presentation-direct-fetch.tsx b/tests/fixtures/architecture/forbidden/presentation-direct-fetch.tsx new file mode 100644 index 0000000..41d7855 --- /dev/null +++ b/tests/fixtures/architecture/forbidden/presentation-direct-fetch.tsx @@ -0,0 +1,4 @@ +export function DirectFetchPage() { + void fetch("/api/forbidden"); + return

forbidden

; +} diff --git a/tests/fixtures/architecture/forbidden/presentation-imports-output-port.tsx b/tests/fixtures/architecture/forbidden/presentation-imports-output-port.tsx new file mode 100644 index 0000000..94951d7 --- /dev/null +++ b/tests/fixtures/architecture/forbidden/presentation-imports-output-port.tsx @@ -0,0 +1,5 @@ +import type { ApplicationOutputPorts } from "../../../../src/application/ports/out/application-output-ports.js"; + +export function OutputPortLeak(_props: ApplicationOutputPorts) { + return

forbidden

; +} diff --git a/tests/fixtures/typecheck/invalid-application-input.ts b/tests/fixtures/typecheck/invalid-application-input.ts new file mode 100644 index 0000000..bb0b1d3 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-application-input.ts @@ -0,0 +1,5 @@ +import type { ApplicationApi } from "../../../src/application/create-application.js"; + +export const incompleteApplication: ApplicationApi = { + session: {} as ApplicationApi["session"], +}; diff --git a/tests/fixtures/typecheck/invalid-application-output.ts b/tests/fixtures/typecheck/invalid-application-output.ts new file mode 100644 index 0000000..18e4d29 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-application-output.ts @@ -0,0 +1,7 @@ +import type { ApplicationOutputPorts } from "../../../src/application/create-application.js"; + +export const invalidOutputPorts: ApplicationOutputPorts = { + session: { + getState: () => "signed-in", + }, +}; diff --git a/tests/helpers/create-test-application.js b/tests/helpers/create-test-application.js new file mode 100644 index 0000000..6a07a14 --- /dev/null +++ b/tests/helpers/create-test-application.js @@ -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", + }), + }, + }); +} diff --git a/tests/unit/application-boundary.test.ts b/tests/unit/application-boundary.test.ts new file mode 100644 index 0000000..bfaf94d --- /dev/null +++ b/tests/unit/application-boundary.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/color-scheme.test.js b/tests/unit/color-scheme.test.js index 8b70c72..03e9021 100644 --- a/tests/unit/color-scheme.test.js +++ b/tests/unit/color-scheme.test.js @@ -21,17 +21,9 @@ describe("color scheme policy", () => { }); it("applies a persisted preference before application paint", () => { - const storage = - /** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({ - read: () => ({ - ok: /** @type {const} */ (true), - value: "system", - }), - write: () => ({ ok: /** @type {const} */ (true) }), - remove: () => ({ ok: /** @type {const} */ (true) }), - }); - - const result = initializeColorScheme(storage, { + const result = initializeColorScheme({ + getColorScheme: () => "system", + }, { documentElement: document.documentElement, matchMedia: () => /** @type {MediaQueryList} */ ({ matches: true }), diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index fe7ffb4..9d9f824 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -33,14 +33,13 @@ describe("runtime adapter composition", () => { host: {}, }); - expect(adapters.authSession.getState()).toBe("unauthenticated"); - expect(adapters.cache.read(["missing"])).toEqual({ - ok: true, - value: undefined, - }); - await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({ + expect(adapters.outputPorts.session.getState()).toBe("unauthenticated"); + await expect(adapters.outputPorts.releaseInfo.getCurrent()).resolves.toMatchObject({ 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 () => { @@ -52,6 +51,6 @@ describe("runtime adapter composition", () => { release, host: {}, }); - expect(adapters.authSession.getState()).toBe("integration-failed"); + expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); }); }); diff --git a/tests/unit/system-clock.test.js b/tests/unit/system-clock.test.js index e2761b9..dd94c1b 100644 --- a/tests/unit/system-clock.test.js +++ b/tests/unit/system-clock.test.js @@ -1,6 +1,6 @@ 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", () => { it("resolves after the requested duration", async () => {