Compare commits

...
13 changed files with 590 additions and 14 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
.tmp/
playwright-report/ playwright-report/
test-results/ test-results/
coverage/ coverage/
+1
View File
@@ -22,6 +22,7 @@
"test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:a11y": "playwright test --grep @a11y", "test:a11y": "playwright test --grep @a11y",
"test:sample-removal": "node scripts/test-sample-removal.mjs",
"test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration"
}, },
"dependencies": { "dependencies": {
+77
View File
@@ -0,0 +1,77 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import path from "node:path";
const fixtureRoot = path.resolve(".tmp/sample-removal");
const sampleRoot = path.resolve("src/sample/contract-fixture");
const sourceRoot = path.resolve("src");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
/** @param {string} directory @returns {Promise<string[]>} */
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = /** @type {string[][]} */ (await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target];
}),
));
return nested.flat();
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
const incomingImports = [];
for (const sourceFile of await sourceFiles(sourceRoot)) {
if (sourceFile.startsWith(sampleRoot)) continue;
const content = await readFile(sourceFile, "utf8");
if (/from\s+["'][^"']*sample\/contract-fixture/.test(content)) {
incomingImports.push(path.relative(".", sourceFile));
}
}
let buildStatus = 1;
if (incomingImports.length === 0) {
await cp("src", path.join(fixtureRoot, "src"), {
recursive: true,
filter: (source) => !source.startsWith(sampleRoot),
});
await cp("public", path.join(fixtureRoot, "public"), { recursive: true });
await cp("index.html", path.join(fixtureRoot, "index.html"));
const result = spawnSync(
process.execPath,
[
pnpmCli,
"exec",
"vite",
"build",
fixtureRoot,
"--outDir",
path.join(fixtureRoot, "dist"),
],
{ stdio: "inherit" },
);
buildStatus = result.status ?? 1;
}
await mkdir("artifacts/tests", { recursive: true });
const passed = incomingImports.length === 0 && buildStatus === 0;
await writeFile(
"artifacts/tests/sample-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="sample-removal" tests="2" failures="${passed ? 0 : 1}">` +
`<testcase name="no-product-import"/>` +
`<testcase name="production-build">${passed ? "" : "<failure/>"}</testcase>` +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
process.stderr.write(
`Sample removal failed. Incoming imports: ${incomingImports.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write("Sample removal smoke: PASS\n");
@@ -0,0 +1,34 @@
const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]);
/**
* @param {{
* failureKind: string,
* manifestLoaded: boolean,
* currentBuildId: string,
* activeReleaseId: string,
* storage: import("../ports/storage-port.js").StoragePort
* }} input
*/
export function decideChunkRecovery(input) {
if (!RECOVERABLE_KINDS.has(input.failureKind)) {
return { action: "support", reason: "not-recoverable" };
}
if (!input.manifestLoaded) {
return { action: "support", reason: "manifest-unavailable" };
}
if (input.activeReleaseId === input.currentBuildId) {
return { action: "support", reason: "same-release" };
}
const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`;
const guard = input.storage.read("CHUNK_RELOAD_GUARD");
if (!guard.ok || guard.value === releasePair) {
return { action: "support", reason: "reload-already-attempted" };
}
const recorded = input.storage.write("CHUNK_RELOAD_GUARD", releasePair);
if (!recorded.ok) {
return { action: "support", reason: "guard-write-failed" };
}
return { action: "reload-once", releasePair };
}
+5 -14
View File
@@ -2,19 +2,10 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js"; 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 { AppRouter } from "../presentation/routes/app-router.jsx";
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
/** @param {{ supportReference: string }} props */
function BootErrorShell({ supportReference }) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<p>지원 참조: {supportReference}</p>
</main>
);
}
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
@@ -35,12 +26,12 @@ async function boot() {
</StrictMode>, </StrictMode>,
); );
} catch (error) { } catch (error) {
const supportReference = const safe =
error instanceof BootConfigError error instanceof BootConfigError
? error.safe.supportReference ? error.safe
: "boot:unknown"; : { supportReference: "boot:unknown" };
root.render(<BootErrorShell supportReference={supportReference} />); root.render(<BootErrorShell {...safe} />);
} }
} }
@@ -0,0 +1,49 @@
/**
* @param {{
* kind?: string,
* code?: string,
* buildId?: string,
* configSchemaVersion?: string,
* releaseId?: string,
* supportReference: string
* }} props
*/
export function BootErrorShell({
kind = "BOOT_CONFIG_FAILURE",
code = "BOOT_FAILED",
buildId,
configSchemaVersion,
releaseId,
supportReference,
}) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<dl>
<dt>오류</dt>
<dd>{kind}</dd>
<dt>코드</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>빌드</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>설정 스키마</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>릴리스</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>지원 참조: {supportReference}</p>
</main>
);
}
@@ -0,0 +1,68 @@
import { Component } from "react";
/**
* @typedef {{
* children: React.ReactNode,
* boundaryName: string,
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* fallback?: React.ReactNode
* }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState
*/
/** @extends {Component<RenderBoundaryProps, RenderBoundaryState>} */
export class RenderErrorBoundary extends Component {
/** @param {RenderBoundaryProps} props */
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch() {
try {
this.props.telemetry?.emit("ui.render.failed", {
route_id: this.props.routeId,
build_id: this.props.buildId,
component_boundary: this.props.boundaryName,
});
} catch {
// Telemetry must never recurse into another render failure.
}
}
reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>error.render_failure</p>
<button type="button" onClick={this.reset}>
retry
</button>
</section>
)
);
}
return this.props.children;
}
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function RouteBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="route" />;
}
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
export function FeatureBoundary(props) {
return <RenderErrorBoundary {...props} boundaryName="feature" />;
}
@@ -0,0 +1,53 @@
import { toResourceViewModel } from "../../application/view-models/resource-view-model.js";
import { queryKeys } from "../../contracts/query-keys.js";
/**
* @param {{
* http: { execute(operationId: string, input?: Record<string, unknown>): Promise<
* {ok: true, value: unknown} | {ok: false, error: import("../../contracts/errors.js").ApiFailure}
* > },
* cache: import("../../application/ports/query-cache-port.js").QueryCachePort
* }} ports
*/
export function createSampleFacade(ports) {
return Object.freeze({
async listResources(filters = {}) {
const key = queryKeys.resource.list(filters);
const result = await ports.http.execute("LIST_SAMPLE_RESOURCES", {
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const models =
/** @type {Array<import("../../domain/models/resource.js").Resource>} */ (
result.value
);
const cached = ports.cache.write(key, models);
if (!cached.ok) return cached;
return {
ok: /** @type {true} */ (true),
value: models.map((model) => toResourceViewModel(model)),
};
},
/** @param {{ name: string }} command */
async createResource(command) {
const result = await ports.http.execute("CREATE_SAMPLE_RESOURCE", {
body: command,
routeId: "SAMPLE_RESOURCE_LIST",
});
if (!result.ok) return result;
const invalidated = await ports.cache.invalidate(queryKeys.resource.all());
if (!invalidated.ok) return invalidated;
return {
ok: /** @type {true} */ (true),
value: toResourceViewModel(
/** @type {import("../../domain/models/resource.js").Resource} */ (
result.value
),
),
};
},
});
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { AsyncSurface } from "../../presentation/components/async-surface.jsx";
/**
* @typedef {{
* loading: boolean,
* resources?: Array<{resourceId: string, title: string, createdAtLabel: string | null}>,
* failure?: import("../../contracts/errors.js").ApiFailure
* }} SamplePageState
*/
/**
* @param {{
* facade: ReturnType<typeof import("./sample-facade.js").createSampleFacade>
* }} props
*/
export function SampleResourcePage({ facade }) {
const [result, setResult] = useState(
/** @type {SamplePageState} */ ({
loading: true,
resources: undefined,
failure: undefined,
}),
);
useEffect(() => {
let active = true;
void facade.listResources().then((outcome) => {
if (!active) return;
setResult(
outcome.ok
? { loading: false, resources: outcome.value, failure: undefined }
: { loading: false, resources: undefined, failure: outcome.error },
);
});
return () => {
active = false;
};
}, [facade]);
const state = deriveAsyncState({
isInitialLoading: result.loading,
data: result.resources,
failure: result.failure,
});
return (
<main>
<h1>샘플 리소스</h1>
<AsyncSurface state={state}>
<ul>
{(result.resources ?? []).map((resource) => (
<li key={resource.resourceId}>{resource.title}</li>
))}
</ul>
</AsyncSurface>
</main>
);
}
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.js";
import { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function Defect() {
throw new Error("raw render stack");
}
describe("render recovery boundaries", () => {
it("catches programmer defects and emits best-effort safe telemetry", () => {
const telemetry = { emit: vi.fn() };
render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
telemetry={telemetry}
>
<Defect />
</FeatureBoundary>,
);
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",
});
});
it("keeps normalized operational failures in normal async state", () => {
const state = deriveAsyncState({
failure: createFailure("SERVER_FAILURE", "LIST", 0),
});
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<AsyncSurface state={state} />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure");
});
it("renders a safe boot shell with no endpoint or stack", () => {
render(
<BootErrorShell
kind="BOOT_CONFIG_FAILURE"
code="CONFIG_SCHEMA_INVALID"
buildId="build-a"
configSchemaVersion="1"
supportReference="build-a:CONFIG_SCHEMA_INVALID"
/>,
);
const shell = screen.getByRole("alert");
expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID");
expect(shell).not.toHaveTextContent("https://");
expect(shell).not.toHaveTextContent("stack");
});
it("allows a boundary reset action without reloading the page", async () => {
let shouldThrow = true;
function Recoverable() {
if (shouldThrow) throw new Error("defect");
return <p>recovered</p>;
}
const user = userEvent.setup();
const view = render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
shouldThrow = false;
await user.click(screen.getByRole("button", { name: "retry" }));
view.rerender(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
</FeatureBoundary>,
);
expect(screen.getByText("recovered")).toBeVisible();
});
});
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SampleResourcePage } from "../../src/sample/contract-fixture/sample-resource-page.jsx";
describe("removable sample feature page", () => {
it("renders the API-to-view-model result through AsyncSurface", async () => {
const facade = {
listResources: async () => ({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
}),
createResource: async () => ({ ok: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(screen.getByLabelText("불러오는 중")).toBeVisible();
expect(await screen.findByText("Example")).toBeVisible();
});
it("renders normalized terminal errors without raw DTO fields", async () => {
const facade = {
listResources: async () => ({
ok: false,
error: {
kind: "SERVER_FAILURE",
code: "SERVER_FAILURE",
retryable: true,
operationId: "LIST_SAMPLE_RESOURCES",
attemptCount: 1,
userMessageKey: "error.server_failure",
action: "retry",
},
}),
createResource: async () => ({ ok: true, value: {} }),
};
render(<SampleResourcePage facade={facade} />);
expect(await screen.findByRole("alert")).toHaveTextContent("error.server_failure");
});
});
@@ -0,0 +1,53 @@
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.js";
import {
createQueryCacheAdapter,
createQueryClient,
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
import { queryKeys } from "../../src/contracts/query-keys.js";
import { createSampleFacade } from "../../src/sample/contract-fixture/sample-facade.js";
const server = setupServer(
http.get("https://api.test/api/sample/resources", () =>
HttpResponse.json({
success: true,
data: [{ id: "resource-1", name: "Example" }],
meta: { requestId: "request-1", traceId: "trace-1" },
}),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
describe("sample vertical contract fixture", () => {
it("traverses API, schema, mapper, application facade, and cache", async () => {
const queryClient = createQueryClient();
const cache = createQueryCacheAdapter(queryClient);
const facade = createSampleFacade({
http: createHttpClient({
baseUrl: "https://api.test",
clock: { now: () => 0, sleep: async () => {} },
}),
cache,
});
await expect(facade.listResources()).resolves.toEqual({
ok: true,
value: [
{
resourceId: "resource-1",
title: "Example",
createdAtLabel: null,
},
],
});
expect(cache.read(queryKeys.resource.list({}))).toMatchObject({
ok: true,
value: [{ id: "resource-1", displayName: "Example" }],
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js";
function memoryStorage() {
let value;
return {
read: () => ({ ok: true, value }),
write: (_key, next) => {
value = next;
return { ok: true };
},
remove: () => ({ ok: true }),
};
}
describe("controlled chunk recovery", () => {
it("records the release pair before allowing one reload", () => {
const storage = memoryStorage();
const input = {
failureKind: "CHUNK_LOAD_FAILURE",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage,
};
expect(decideChunkRecovery(input)).toEqual({
action: "reload-once",
releasePair: "build-a->release-b",
});
expect(decideChunkRecovery(input)).toEqual({
action: "support",
reason: "reload-already-attempted",
});
});
it.each([
[{ failureKind: "SERVER_FAILURE" }, "not-recoverable"],
[{ manifestLoaded: false }, "manifest-unavailable"],
[{ activeReleaseId: "build-a" }, "same-release"],
])("stops when a recovery invariant fails: %#", (override, reason) => {
const result = decideChunkRecovery({
failureKind: "DEPLOY_MISMATCH",
manifestLoaded: true,
currentBuildId: "build-a",
activeReleaseId: "release-b",
storage: memoryStorage(),
...override,
});
expect(result).toEqual({ action: "support", reason });
});
});