feat: execute route and release recovery contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:26:39 +09:00
parent a33e93d4d4
commit ce0040e407
49 changed files with 1761 additions and 373 deletions
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ChunkRecoveryBoundary,
isChunkLoadFailure,
} from "../../src/presentation/boundaries/chunk-recovery-boundary.js";
import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx";
function ChunkDefect(): never {
throw new TypeError("Failed to fetch dynamically imported module");
}
function RenderDefect(): never {
throw new Error("ordinary render defect");
}
describe("chunk recovery boundary classification", () => {
it("recognizes lazy module failures without classifying ordinary render errors", () => {
expect(
isChunkLoadFailure(
new TypeError("Failed to fetch dynamically imported module"),
),
).toBe(true);
expect(isChunkLoadFailure(new Error("ordinary render defect"))).toBe(false);
});
it("runs the recovery input only for a lazy chunk rejection", async () => {
const recover = vi.fn(async () => ({
action: "support" as const,
reason: "reload-already-attempted",
}));
render(
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<ChunkDefect />
</ChunkRecoveryBoundary>,
);
expect(
await screen.findByRole("heading", {
name: "화면 자산을 복구하지 못했습니다.",
}),
).toBeVisible();
expect(recover).toHaveBeenCalledOnce();
expect(recover).toHaveBeenCalledWith({
chunkId: "route-home",
failureKind: "CHUNK_LOAD_FAILURE",
});
});
it("rethrows an ordinary component defect to the local render boundary", () => {
const recover = vi.fn();
render(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<ChunkRecoveryBoundary chunkId="route-home" recover={recover}>
<RenderDefect />
</ChunkRecoveryBoundary>
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(recover).not.toHaveBeenCalled();
});
});
+30
View File
@@ -91,4 +91,34 @@ describe("render recovery boundaries", () => {
);
expect(screen.getByText("recovered")).toBeVisible();
});
it("resets a route failure when the registered location key changes", async () => {
let shouldThrow = true;
function RouteContent() {
if (shouldThrow) throw new Error("route defect");
return <p>next route</p>;
}
const view = render(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
resetKey="/first"
>
<RouteContent />
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toBeVisible();
shouldThrow = false;
view.rerender(
<FeatureBoundary
routeId="APP_HOME"
buildId="build-a"
resetKey="/second"
>
<RouteContent />
</FeatureBoundary>,
);
expect(await screen.findByText("next route")).toBeVisible();
});
});
+37 -3
View File
@@ -2,7 +2,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createAnonymousSessionAdapter,
@@ -14,10 +14,13 @@ import { createTestApplication } from "../helpers/create-test-application.js";
/**
* @param {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} session
* @param {Parameters<typeof createTestApplication>[0]} [overrides]
*/
function renderRouter(session) {
function renderRouter(session, overrides = {}) {
return render(
<ApplicationProvider application={createTestApplication({ session })}>
<ApplicationProvider
application={createTestApplication({ ...overrides, session })}
>
<AppRouter />
</ApplicationProvider>,
);
@@ -50,6 +53,10 @@ describe("application router", () => {
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui");
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
expect(
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
).toHaveFocus();
});
it("reacts to demo sign-in and opens the protected integration route", async () => {
@@ -82,4 +89,31 @@ describe("application router", () => {
screen.getByRole("heading", { name: "세션이 필요합니다." }),
).toBeVisible();
});
it("rejects invalid route search before any application query runs", async () => {
const getCurrent = vi.fn(async () => ({
buildId: "test-build",
releaseId: "test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "test-hash",
routeChunks: { "route-sample-resources": "assets/sample.js" },
}));
window.history.pushState({}, "", "/sample/resources?limit=invalid");
renderRouter(createDemoSessionAdapter("authenticated"), {
releaseInfo: { getCurrent, refresh: getCurrent },
});
expect(
await screen.findByRole("heading", {
name: "올바르지 않은 주소입니다.",
}),
).toBeVisible();
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByText("안전한 탐색 링크를 사용해 주세요.")).toHaveAttribute(
"data-route-error",
"ROUTE_SEARCH_INVALID",
);
expect(getCurrent).not.toHaveBeenCalled();
});
});
@@ -30,6 +30,14 @@ const releaseManifest = {
assetManifestHash: "test-hash",
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
routeChunks: {
"route-home": "assets/home.js",
"route-examples-ui": "assets/ui.js",
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-sample-resources": "assets/sample.js",
"route-not-found": "assets/not-found.js",
},
};
describe("production runtime application tree", () => {