feat: compose executable frontend runtime
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
"REQUEST_TIMEOUT_MS": 10000,
|
||||
"MAX_RETRY_ATTEMPTS": 2,
|
||||
"TELEMETRY_ENABLED": false,
|
||||
"AUTH_MODE": "external",
|
||||
"AUTH_MODE": "demo",
|
||||
"CONFIG_SCHEMA_VERSION": "1",
|
||||
"API_CONTRACT_VERSION": "1",
|
||||
"RELEASE_MANIFEST_URL": "/release-manifest.json",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*
|
||||
* @param {{
|
||||
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
|
||||
* subscribe(listener: () => void): () => void,
|
||||
* beginSignIn(returnTo?: string): Promise<void>,
|
||||
* signOut(): Promise<void>,
|
||||
* attachCredential(request: Request): Promise<Request>,
|
||||
* recoverSession(): Promise<"restored" | "no-session">,
|
||||
* notifyUnauthenticated(): void
|
||||
@@ -15,6 +18,16 @@ export function createExternalAuthSessionAdapter(owner) {
|
||||
getState() {
|
||||
return owner.readState();
|
||||
},
|
||||
subscribe(listener) {
|
||||
return owner.subscribe(listener);
|
||||
},
|
||||
async beginSignIn(returnTo) {
|
||||
await owner.beginSignIn(returnTo);
|
||||
},
|
||||
async signOut() {
|
||||
await owner.signOut();
|
||||
},
|
||||
/** @param {Request} request */
|
||||
async attach(request) {
|
||||
const attached = await owner.attachCredential(request);
|
||||
if (!(attached instanceof Request)) {
|
||||
@@ -38,8 +51,74 @@ export function createExternalAuthSessionAdapter(owner) {
|
||||
export function createAnonymousSessionAdapter() {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "unauthenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "no-session",
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Local/test-only session seam. It never creates or stores credentials.
|
||||
*
|
||||
* @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState]
|
||||
*/
|
||||
export function createDemoSessionAdapter(initialState = "unauthenticated") {
|
||||
let state = initialState;
|
||||
const listeners = new Set();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** @param {import("../../application/ports/auth-session-port.js").SessionState} next */
|
||||
function setState(next) {
|
||||
state = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
getState: () => state,
|
||||
/** @param {() => void} listener */
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async beginSignIn() {
|
||||
setState("authenticated");
|
||||
},
|
||||
async signOut() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
/** @param {Request} request */
|
||||
async attach(request) {
|
||||
return request;
|
||||
},
|
||||
async recover() {
|
||||
if (state === "recovery-pending") {
|
||||
setState("authenticated");
|
||||
return /** @type {const} */ ("restored");
|
||||
}
|
||||
return /** @type {const} */ ("no-session");
|
||||
},
|
||||
onUnauthenticated() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
setState,
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnavailableSessionAdapter() {
|
||||
return Object.freeze({
|
||||
getState: () => /** @type {const} */ ("integration-failed"),
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
/** @param {Request} request */
|
||||
attach: async (request) => request,
|
||||
recover: async () => /** @type {const} */ ("no-session"),
|
||||
onUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Application facade factory. Concrete dependencies are supplied by bootstrap.
|
||||
*
|
||||
* @param {{
|
||||
* resources: {
|
||||
* resources?: {
|
||||
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
|
||||
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
|
||||
* },
|
||||
@@ -17,7 +17,9 @@ export function createApplication(ports) {
|
||||
* @param {import("./ports/resource-ports.js").RequestContext} [context]
|
||||
*/
|
||||
function queryResources(query, context) {
|
||||
return ports.resources.query.execute(query, context);
|
||||
return /** @type {NonNullable<typeof ports.resources>} */ (
|
||||
ports.resources
|
||||
).query.execute(query, context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,14 +27,18 @@ export function createApplication(ports) {
|
||||
* @param {import("./ports/resource-ports.js").RequestContext} [context]
|
||||
*/
|
||||
function commandResources(command, context) {
|
||||
return ports.resources.command.execute(command, context);
|
||||
return /** @type {NonNullable<typeof ports.resources>} */ (
|
||||
ports.resources
|
||||
).command.execute(command, context);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
resources: Object.freeze({
|
||||
query: queryResources,
|
||||
command: commandResources,
|
||||
}),
|
||||
resources: ports.resources
|
||||
? Object.freeze({
|
||||
query: queryResources,
|
||||
command: commandResources,
|
||||
})
|
||||
: null,
|
||||
cache: ports.cache,
|
||||
storage: ports.storage,
|
||||
telemetry: ports.telemetry,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
*
|
||||
* @typedef {{
|
||||
* getState(): SessionState,
|
||||
* subscribe(listener: () => void): () => void,
|
||||
* beginSignIn(returnTo?: string): Promise<void>,
|
||||
* signOut(): Promise<void>,
|
||||
* attach(request: Request): Promise<Request>,
|
||||
* recover(): Promise<"restored" | "no-session">,
|
||||
* onUnauthenticated(): void
|
||||
|
||||
@@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js";
|
||||
* This is the only module allowed to join concrete adapters to application
|
||||
* ports. Boot phases are explicit so failures can stop before product mount.
|
||||
*
|
||||
* @template Config
|
||||
* @template Release
|
||||
* @template {Parameters<typeof createApplication>[0]} Ports
|
||||
* @param {{
|
||||
* loadConfig(): Promise<Record<string, unknown>>,
|
||||
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
|
||||
* loadConfig(): Promise<Config>,
|
||||
* loadRelease(config: Config): Promise<Release>,
|
||||
* createAdapters(context: {
|
||||
* config: Record<string, unknown>,
|
||||
* release: Record<string, unknown>
|
||||
* }): Promise<Parameters<typeof createApplication>[0]>
|
||||
* config: Config,
|
||||
* release: Release
|
||||
* }): Promise<Ports>
|
||||
* }} factories
|
||||
* @returns {Promise<Readonly<{
|
||||
* config: Config,
|
||||
* release: Release,
|
||||
* ports: Ports,
|
||||
* application: ReturnType<typeof createApplication>
|
||||
* }>>}
|
||||
*/
|
||||
export async function createCompositionRoot(factories) {
|
||||
const config = await factories.loadConfig();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createCompositionRoot } from "./composition-root.js";
|
||||
import { loadReleaseManifest } from "./load-release-manifest.js";
|
||||
import { loadRuntimeConfig } from "./load-runtime-config.js";
|
||||
import { createRuntimeAdapters } from "./runtime-adapters.js";
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* fetcher?: typeof fetch,
|
||||
* host?: Record<string, unknown>
|
||||
* }} [dependencies]
|
||||
*/
|
||||
export function createRuntimeComposition(dependencies = {}) {
|
||||
return createCompositionRoot({
|
||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||
loadRelease: (runtime) =>
|
||||
loadReleaseManifest(
|
||||
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
|
||||
{ fetcher: dependencies.fetcher },
|
||||
),
|
||||
createAdapters: ({ config: runtime, release }) =>
|
||||
createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Awaited<ReturnType<typeof loadRuntimeConfig>>} */ (runtime),
|
||||
release:
|
||||
/** @type {Awaited<ReturnType<typeof loadReleaseManifest>>} */ (
|
||||
release
|
||||
),
|
||||
fetcher: dependencies.fetcher,
|
||||
host: dependencies.host,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
|
||||
const releaseManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
appVersion: z.string().min(1),
|
||||
buildId: z.string().min(1),
|
||||
commitSha: z.string().min(1),
|
||||
configSchemaVersion: version,
|
||||
apiContractVersion: version,
|
||||
assetManifestHash: z.string().min(1),
|
||||
releaseId: z.string().min(1),
|
||||
builtAt: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class ReleaseManifestError extends Error {
|
||||
/** @param {string} code @param {{buildId: string, releaseId?: string}} safe */
|
||||
constructor(code, safe) {
|
||||
super("Release manifest could not be loaded");
|
||||
this.name = "ReleaseManifestError";
|
||||
this.kind = "RELEASE_MANIFEST_FAILURE";
|
||||
this.code = code;
|
||||
this.safe = Object.freeze({
|
||||
kind: this.kind,
|
||||
code,
|
||||
buildId: safe.buildId,
|
||||
releaseId: safe.releaseId,
|
||||
supportReference: `${safe.buildId}:${code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>} runtime
|
||||
* @param {{fetcher?: typeof fetch}} [options]
|
||||
*/
|
||||
export async function loadReleaseManifest(runtime, options = {}) {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
let response;
|
||||
try {
|
||||
response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
let raw;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch {
|
||||
throw new ReleaseManifestError("MANIFEST_JSON_INVALID", {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
}
|
||||
const parsed = releaseManifestSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = parsed.data;
|
||||
const mismatches = [];
|
||||
if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId");
|
||||
if (
|
||||
runtime.config.BUILD_ID &&
|
||||
manifest.buildId !== runtime.config.BUILD_ID
|
||||
) {
|
||||
mismatches.push("runtimeBuildId");
|
||||
}
|
||||
if (
|
||||
manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION
|
||||
) {
|
||||
mismatches.push("configSchemaVersion");
|
||||
}
|
||||
if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) {
|
||||
mismatches.push("apiContractVersion");
|
||||
}
|
||||
if (
|
||||
runtime.config.RELEASE_ID &&
|
||||
manifest.releaseId !== runtime.config.RELEASE_ID
|
||||
) {
|
||||
mismatches.push("releaseId");
|
||||
}
|
||||
if (mismatches.length > 0) {
|
||||
throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", {
|
||||
buildId: runtime.build.buildId,
|
||||
releaseId: runtime.config.RELEASE_ID,
|
||||
});
|
||||
}
|
||||
return Object.freeze(structuredClone(manifest));
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
|
||||
import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx";
|
||||
import { AppRouter } from "../presentation/routes/app-router.jsx";
|
||||
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
|
||||
import { createRuntimeComposition } from "./create-runtime-composition.js";
|
||||
import { BootConfigError } from "./load-runtime-config.js";
|
||||
import { ReleaseManifestError } from "./load-release-manifest.js";
|
||||
import "../presentation/styles/theme.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
@@ -17,18 +18,18 @@ const root = createRoot(rootElement);
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const runtime = await loadRuntimeConfig();
|
||||
const composition = await createRuntimeComposition();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<AppRouter
|
||||
authSession={createAnonymousSessionAdapter()}
|
||||
basename={runtime.build.routerBasePath}
|
||||
authSession={composition.ports.authSession}
|
||||
basename={composition.config.build.routerBasePath}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
} catch (error) {
|
||||
const safe =
|
||||
error instanceof BootConfigError
|
||||
error instanceof BootConfigError || error instanceof ReleaseManifestError
|
||||
? error.safe
|
||||
: { supportReference: "boot:unknown" };
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
createDemoSessionAdapter,
|
||||
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";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js";
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} host
|
||||
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0] | null}
|
||||
*/
|
||||
function externalOwnerFrom(host) {
|
||||
const candidate = host.__CA_FRONTEND_AUTH_OWNER__;
|
||||
if (!candidate || typeof candidate !== "object") return null;
|
||||
const owner = /** @type {Record<string, unknown>} */ (candidate);
|
||||
const required = [
|
||||
"readState",
|
||||
"subscribe",
|
||||
"beginSignIn",
|
||||
"signOut",
|
||||
"attachCredential",
|
||||
"recoverSession",
|
||||
"notifyUnauthenticated",
|
||||
];
|
||||
return required.every((name) => typeof owner[name] === "function")
|
||||
? /** @type {Parameters<typeof createExternalAuthSessionAdapter>[0]} */ (
|
||||
candidate
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function storageOrUndefined(value) {
|
||||
return typeof Storage !== "undefined" && value instanceof Storage
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
|
||||
* release: Awaited<ReturnType<typeof import("./load-release-manifest.js").loadReleaseManifest>>,
|
||||
* host?: Record<string, unknown>,
|
||||
* fetcher?: typeof fetch
|
||||
* }} context
|
||||
*/
|
||||
export async function createRuntimeAdapters(context) {
|
||||
const host = context.host ?? /** @type {Record<string, unknown>} */ (globalThis);
|
||||
const config = context.runtime.config;
|
||||
const externalOwner = externalOwnerFrom(host);
|
||||
const authSession =
|
||||
config.AUTH_MODE === "demo"
|
||||
? createDemoSessionAdapter()
|
||||
: externalOwner
|
||||
? createExternalAuthSessionAdapter(externalOwner)
|
||||
: createUnavailableSessionAdapter();
|
||||
const queryClient = createQueryClient();
|
||||
const cache = createQueryCacheAdapter(queryClient);
|
||||
const storage = createBrowserStorageAdapter({
|
||||
localStorage: storageOrUndefined(host.localStorage),
|
||||
sessionStorage: storageOrUndefined(host.sessionStorage),
|
||||
});
|
||||
const telemetry = createTelemetryAdapter({
|
||||
enabled: config.TELEMETRY_ENABLED,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
authSession,
|
||||
cache,
|
||||
http,
|
||||
queryClient,
|
||||
releaseInfo,
|
||||
storage,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const runtimeConfigSchema = z
|
||||
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
|
||||
TELEMETRY_ENABLED: z.boolean(),
|
||||
TELEMETRY_ENDPOINT: z.url().optional(),
|
||||
AUTH_MODE: z.literal("external"),
|
||||
AUTH_MODE: z.enum(["external", "demo"]),
|
||||
CONFIG_SCHEMA_VERSION: version,
|
||||
API_CONTRACT_VERSION: version,
|
||||
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
|
||||
@@ -28,6 +28,13 @@ export const runtimeConfigSchema = z
|
||||
}
|
||||
|
||||
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
|
||||
if (!local && config.AUTH_MODE === "demo") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["AUTH_MODE"],
|
||||
message: "demo authentication is limited to local environments",
|
||||
});
|
||||
}
|
||||
const endpointEntries =
|
||||
/** @type {Array<[string, string | undefined]>} */ ([
|
||||
["API_BASE_URL", config.API_BASE_URL],
|
||||
|
||||
@@ -21,8 +21,9 @@ test("@a11y keyboard reaches the primary route action with visible focus", async
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.keyboard.press("Tab");
|
||||
const action = page.getByRole("link", { name: "샘플 리소스" });
|
||||
await expect(action).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(action).toBeFocused();
|
||||
await expect(action).toHaveCSS("outline-style", "solid");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
loadReleaseManifest,
|
||||
ReleaseManifestError,
|
||||
} from "../../src/bootstrap/load-release-manifest.js";
|
||||
|
||||
const runtime = {
|
||||
build: { buildId: "build-a" },
|
||||
config: {
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "build-a",
|
||||
RELEASE_ID: "release-a",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
},
|
||||
};
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "hash-a",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("release manifest boot boundary", () => {
|
||||
it("loads a coherent release tuple", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(
|
||||
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
|
||||
{ fetcher: async () => new Response(JSON.stringify(manifest)) },
|
||||
),
|
||||
).resolves.toMatchObject({ releaseId: "release-a" });
|
||||
});
|
||||
|
||||
it("fails before mount when release and runtime differ", async () => {
|
||||
await expect(
|
||||
loadReleaseManifest(
|
||||
/** @type {Parameters<typeof loadReleaseManifest>[0]} */ (runtime),
|
||||
{
|
||||
fetcher: async () =>
|
||||
new Response(JSON.stringify({ ...manifest, buildId: "build-b" })),
|
||||
},
|
||||
),
|
||||
).rejects.toBeInstanceOf(ReleaseManifestError);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,20 @@ describe("runtime configuration boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows demo authentication only for local runtime configuration", () => {
|
||||
expect(
|
||||
validateRuntimeConfig({ ...validConfig, AUTH_MODE: "demo" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateRuntimeConfig({
|
||||
...validConfig,
|
||||
APP_ENV: "production",
|
||||
API_BASE_URL: "https://api.example.test",
|
||||
AUTH_MODE: "demo",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("validates a fetched config under the 500ms budget excluding network", async () => {
|
||||
let current = 100;
|
||||
const result = await loadRuntimeConfig({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
} from "../../src/adapters/auth/external-session-adapter.js";
|
||||
|
||||
@@ -9,6 +10,9 @@ describe("external AuthSessionPort adapter", () => {
|
||||
it("attaches opaque credentials without exposing a token-shaped session", async () => {
|
||||
const adapter = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("X-Session-Attached", "true");
|
||||
@@ -28,6 +32,9 @@ describe("external AuthSessionPort adapter", () => {
|
||||
it("fails invalid recovery states closed", async () => {
|
||||
const adapter = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "unexpected",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
@@ -41,4 +48,20 @@ describe("external AuthSessionPort adapter", () => {
|
||||
expect(adapter.getState()).toBe("unauthenticated");
|
||||
await expect(adapter.recover()).resolves.toBe("no-session");
|
||||
});
|
||||
|
||||
it("provides a reactive credential-free demo seam", async () => {
|
||||
const adapter = createDemoSessionAdapter();
|
||||
let notifications = 0;
|
||||
const unsubscribe = adapter.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
expect(adapter.getState()).toBe("unauthenticated");
|
||||
await adapter.beginSignIn("/");
|
||||
expect(adapter.getState()).toBe("authenticated");
|
||||
await adapter.signOut();
|
||||
expect(adapter.getState()).toBe("unauthenticated");
|
||||
expect(notifications).toBe(2);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js";
|
||||
|
||||
const runtime = {
|
||||
config: {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080",
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
},
|
||||
};
|
||||
const release = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: "hash-a",
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("runtime adapter composition", () => {
|
||||
it("constructs the local demo seam and infrastructure adapters", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
|
||||
expect(adapters.authSession.getState()).toBe("unauthenticated");
|
||||
expect(adapters.cache.read(["missing"])).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({
|
||||
releaseId: "release-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when an external auth owner was not installed", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ ({
|
||||
config: { ...runtime.config, AUTH_MODE: "external" },
|
||||
}),
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
expect(adapters.authSession.getState()).toBe("integration-failed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user