127 lines
3.8 KiB
React
127 lines
3.8 KiB
React
// @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";
|
|
|
|
/** @returns {import("react").ReactNode} */
|
|
function Defect() {
|
|
throw new Error("raw render stack");
|
|
}
|
|
|
|
describe("render recovery boundaries", () => {
|
|
it("catches programmer defects and emits best-effort safe telemetry", () => {
|
|
const onRenderFailure = vi.fn();
|
|
render(
|
|
<FeatureBoundary
|
|
routeId="APP_HOME"
|
|
buildId="build-a"
|
|
onRenderFailure={onRenderFailure}
|
|
>
|
|
<Defect />
|
|
</FeatureBoundary>,
|
|
);
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"화면을 표시하지 못했습니다.",
|
|
);
|
|
expect(onRenderFailure).toHaveBeenCalledWith({
|
|
routeId: "APP_HOME",
|
|
buildId: "build-a",
|
|
boundaryName: "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(
|
|
"요청을 완료하지 못했습니다.",
|
|
);
|
|
expect(screen.getByRole("alert")).toHaveAttribute(
|
|
"data-message-key",
|
|
"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: "다시 시도" }));
|
|
view.rerender(
|
|
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
|
<Recoverable />
|
|
</FeatureBoundary>,
|
|
);
|
|
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();
|
|
});
|
|
});
|