feat: establish TypeScript-aware frontend tooling
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SafeTextFixture({ value }: Readonly<{ value: string }>) {
|
||||
return <p>{value}</p>;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user