feat: establish TypeScript-aware frontend tooling

This commit is contained in:
donghyeon-ka
2026-07-26 13:41:23 +09:00
parent 1a1747c737
commit 0fed35586a
41 changed files with 1086 additions and 125 deletions
+1
View File
@@ -10,6 +10,7 @@ import { AsyncSurface } from "../../src/presentation/components/async-surface.js
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
/** @returns {import("react").ReactNode} */
function Defect() {
throw new Error("raw render stack");
}
+1 -1
View File
@@ -30,7 +30,7 @@ describe("application router", () => {
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
await user.click(
await screen.findByRole("link", { name: "UI 구성요소", exact: true }),
await screen.findByRole("link", { name: "UI 구성요소" }),
);
expect(
+20 -4
View File
@@ -7,9 +7,10 @@ import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-res
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: true,
ok: /** @type {const} */ (true),
value: [
{
resourceId: "resource-1",
@@ -18,7 +19,14 @@ describe("removable sample feature page", () => {
},
],
}),
createResource: async () => ({ ok: true, value: {} }),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
@@ -27,9 +35,10 @@ describe("removable sample feature page", () => {
});
it("renders normalized terminal errors without raw DTO fields", async () => {
/** @type {Parameters<typeof SampleResourcePage>[0]["facade"]} */
const facade = {
listResources: async () => ({
ok: false,
ok: /** @type {const} */ (false),
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
@@ -40,7 +49,14 @@ describe("removable sample feature page", () => {
action: "retry",
},
}),
createResource: async () => ({ ok: true, value: {} }),
createResource: async () => ({
ok: /** @type {const} */ (true),
value: {
resourceId: "resource-created",
title: "Created",
createdAtLabel: null,
},
}),
};
render(<SampleResourcePage facade={facade} />);
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
type StatusProps = Readonly<{
label: string;
tone: "neutral" | "positive";
}>;
function Status({ label, tone }: StatusProps) {
return <output data-tone={tone}>{label}</output>;
}
describe("TSX test tooling", () => {
it("parses, lints, type-checks, and renders TSX", () => {
render(<Status label="ready" tone="positive" />);
expect(screen.getByText("ready")).toHaveAttribute("data-tone", "positive");
});
});
@@ -0,0 +1,7 @@
import { createApplication } from "../../../../src/application/create-application.js";
export const applicationFactory = createApplication;
export function AllowedPresentationFixture() {
return <p>allowed</p>;
}
@@ -0,0 +1,5 @@
import "../../../../src/adapters/http/client.js";
export function InvalidPresentationFixture() {
return <p>invalid</p>;
}
+33
View File
@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"sourceDirectories": [],
"registries": [
{
"registryId": "FIXTURE-SOURCE",
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
"exportName": "INVALID_REGISTRY",
"owner": "fixture",
"requiredFields": ["id", "target"],
"uniqueFields": ["id"],
"references": [
{
"field": "target",
"registryId": "FIXTURE-TARGET",
"targetField": "id"
}
]
},
{
"registryId": "FIXTURE-TARGET",
"path": "tests/fixtures/registry/forbidden/invalid-registry.js",
"exportName": "TARGET_REGISTRY",
"owner": "fixture",
"requiredFields": ["id"],
"uniqueFields": ["id"]
}
],
"compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "breaking"
}
}
+13
View File
@@ -0,0 +1,13 @@
type InvalidRow = Readonly<{
id: string;
target?: string;
}>;
export const INVALID_REGISTRY: Record<string, InvalidRow> = {
FIRST: { id: "duplicate", target: "missing" },
SECOND: { id: "duplicate" },
};
export const TARGET_REGISTRY = {
KNOWN: { id: "known" },
} as const;
+3
View File
@@ -0,0 +1,3 @@
export function SafeTextFixture({ value }: Readonly<{ value: string }>) {
return <p>{value}</p>;
}
+3
View File
@@ -0,0 +1,3 @@
export function UnsafeHtmlFixture({ value }: Readonly<{ value: string }>) {
return <div dangerouslySetInnerHTML={{ __html: value }} />;
}
@@ -0,0 +1,7 @@
interface ClockPort {
now(): number;
}
export const invalidClock: ClockPort = {
now: () => "not-a-number",
};
+7
View File
@@ -0,0 +1,7 @@
type Result<T, E> =
| Readonly<{ ok: true; value: T }>
| Readonly<{ ok: false; error: E }>;
export function invalidUnwrap(result: Result<number, string>): number {
return result.value;
}
+25 -10
View File
@@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../../src/adapters/http/client.js";
/** @type {number[]} */
let responseStatuses = [];
const server = setupServer(
http.get("https://api.test/api/sample/resources", () => {
@@ -36,16 +37,32 @@ afterAll(() => server.close());
const clock = { now: () => 0, sleep: async () => {} };
/**
* @param {Partial<Parameters<typeof createExternalAuthSessionAdapter>[0]>} overrides
* @returns {Parameters<typeof createExternalAuthSessionAdapter>[0]}
*/
function createOwner(overrides = {}) {
return {
readState: () => "authenticated",
subscribe: () => () => {},
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
recoverSession: async () => "restored",
notifyUnauthenticated: () => {},
...overrides,
};
}
describe("bounded 401 session recovery", () => {
it("calls recovery once and replays a safe request once", async () => {
responseStatuses = [401, 200];
const recoverSession = vi.fn(async () => "restored");
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const recoverSession = vi.fn(async () => /** @type {const} */ ("restored"));
const authSession = createExternalAuthSessionAdapter(createOwner({
attachCredential: async (request) => request,
recoverSession,
notifyUnauthenticated: vi.fn(),
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
@@ -61,12 +78,11 @@ describe("bounded 401 session recovery", () => {
it("stops after a second 401 and notifies unauthenticated once", async () => {
responseStatuses = [401, 401];
const notifyUnauthenticated = vi.fn();
const authSession = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const authSession = createExternalAuthSessionAdapter(createOwner({
attachCredential: async (request) => request,
recoverSession: async () => "restored",
notifyUnauthenticated,
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession,
@@ -81,14 +97,13 @@ describe("bounded 401 session recovery", () => {
});
it("normalizes attach and invalid recovery failures", async () => {
const attachFailure = createExternalAuthSessionAdapter({
readState: () => "authenticated",
const attachFailure = createExternalAuthSessionAdapter(createOwner({
attachCredential: async () => {
throw new Error("credential detail");
},
recoverSession: async () => "restored",
notifyUnauthenticated: vi.fn(),
});
}));
const client = createHttpClient({
baseUrl: "https://api.test",
authSession: attachFailure,
+1
View File
@@ -31,6 +31,7 @@ describe("HTTP runtime schema boundary", () => {
success: true,
data: [{ id: "resource-1", additive: "accepted" }],
});
if (!result.success) throw new Error("expected valid sample payload");
expect(result.data).not.toBe(source);
});
+2 -1
View File
@@ -25,7 +25,8 @@ describe("runtime configuration boundary", () => {
[{ ...validConfig, TELEMETRY_ENABLED: "false" }, "ambiguous boolean"],
[{ ...validConfig, CONFIG_SCHEMA_VERSION: "next" }, "version"],
[{ ...validConfig, UNKNOWN_KEY: true }, "unknown key"],
])("rejects invalid config: %s (%s)", (candidate) => {
])("rejects invalid config: %s (%s)", (candidate, _reason) => {
void _reason;
expect(validateRuntimeConfig(candidate).success).toBe(false);
});
+2 -1
View File
@@ -36,6 +36,7 @@ describe("external AuthSessionPort adapter", () => {
beginSignIn: async () => {},
signOut: async () => {},
attachCredential: async (request) => request,
// @ts-expect-error Deliberately violates the external-owner contract.
recoverSession: async () => "unexpected",
notifyUnauthenticated: vi.fn(),
});
@@ -57,7 +58,7 @@ describe("external AuthSessionPort adapter", () => {
});
expect(adapter.getState()).toBe("unauthenticated");
await adapter.beginSignIn("/");
await adapter.beginSignIn();
expect(adapter.getState()).toBe("authenticated");
await adapter.signOut();
expect(adapter.getState()).toBe("unauthenticated");
+6 -5
View File
@@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest";
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js";
function memoryStorage() {
/** @type {unknown} */
let value;
return {
read: () => ({ ok: true, value }),
return /** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({
read: () => ({ ok: /** @type {const} */ (true), value }),
write: (_key, next) => {
value = next;
return { ok: true };
return { ok: /** @type {const} */ (true) };
},
remove: () => ({ ok: true }),
};
remove: () => ({ ok: /** @type {const} */ (true) }),
});
}
describe("controlled chunk recovery", () => {
+9 -5
View File
@@ -21,11 +21,15 @@ describe("color scheme policy", () => {
});
it("applies a persisted preference before application paint", () => {
const storage = {
read: () => ({ ok: true, value: "system" }),
write: () => ({ ok: true }),
remove: () => ({ ok: true }),
};
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, {
documentElement: document.documentElement,
+8 -2
View File
@@ -39,14 +39,20 @@ describe("frontend failure classification", () => {
});
it("projects only allowlisted safe fields", () => {
const result = createFailure("SERVER_FAILURE", "LIST_SAMPLE_RESOURCES", 0, {
const untrustedDetails = {
code: "TEMPORARY",
httpStatus: 503,
requestId: "request-1",
stack: "must not leak",
body: "must not leak",
authorization: "Bearer secret",
});
};
const result = createFailure(
"SERVER_FAILURE",
"LIST_SAMPLE_RESOURCES",
0,
untrustedDetails,
);
expect(result).toMatchObject({
kind: "SERVER_FAILURE",
+9 -2
View File
@@ -6,7 +6,10 @@ import {
} from "../../src/application/policies/promotion-readiness.js";
const allGateIds = Object.values(PROMOTION_FORMULA).flat();
const passing = Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]));
const passing =
/** @type {Record<string, "PASS" | "FAIL" | "UNVERIFIED">} */ (
Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]))
);
describe("promotion readiness formula", () => {
it("requires every upstream tier before downstream readiness", () => {
@@ -30,7 +33,11 @@ describe("promotion readiness formula", () => {
...passing,
[failedGate]: "FAIL",
});
expect(result[readiness]).toBe(false);
const readinessKey =
/** @type {keyof ReturnType<typeof evaluatePromotionReadiness>} */ (
readiness
);
expect(result[readinessKey]).toBe(false);
});
it("does not treat missing or unverified gates as pass", () => {
+6 -2
View File
@@ -6,11 +6,15 @@ describe("registry governance manifest", () => {
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
);
const registries =
/** @type {Array<{registryId: string, owner: string}>} */ (
governance.registries
);
expect(governance.registries).toHaveLength(8);
expect(new Set(governance.registries.map((entry) => entry.registryId)).size).toBe(
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
8,
);
expect(governance.registries.every((entry) => entry.owner)).toBe(true);
expect(registries.every((entry) => entry.owner)).toBe(true);
expect(governance.compatibilityImpact.allowed).toEqual([
"none",
"additive",
+2 -2
View File
@@ -10,7 +10,7 @@ const runtime = {
AUTH_MODE: "demo",
},
};
const release = {
const release = /** @type {const} */ ({
schemaVersion: 1,
appVersion: "0.1.0",
buildId: "build-a",
@@ -20,7 +20,7 @@ const release = {
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 () => {
+5 -1
View File
@@ -7,8 +7,12 @@ import {
defineStorageKey,
} from "../../src/contracts/storage-keys.js";
/**
* @param {{quota?: boolean}} [options]
* @returns {Storage}
*/
function createStorage({ quota = false } = {}) {
const values = new Map();
const values = /** @type {Map<string, string>} */ (new Map());
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
+1 -1
View File
@@ -56,7 +56,7 @@ describe("telemetry registry and redaction", () => {
describe("best-effort telemetry adapter", () => {
it("bounds the queue using oldest-drop without blocking callers", () => {
const scheduled = [];
const scheduled = /** @type {Array<() => void>} */ ([]);
const adapter = createTelemetryAdapter({
enabled: true,
endpoint: "https://telemetry.test/events",
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
type Result<T, E> =
| Readonly<{ ok: true; value: T }>
| Readonly<{ ok: false; error: E }>;
function summarize(result: Result<number, string>): string {
return result.ok ? String(result.value) : result.error;
}
describe("TypeScript test tooling", () => {
it("type-checks and executes discriminated unions in the test project", () => {
expect(summarize({ ok: true, value: 42 })).toBe("42");
expect(summarize({ ok: false, error: "failed" })).toBe("failed");
});
});