CLS was 0.192 on every public route, and one element accounted for all of it: the footer moved at t≈482ms, right when the lazily loaded route chunk arrived. The site frame laid the footer out in normal flow, so before the content existed it sat at the bottom edge of the viewport — visible — and then dropped out of view when the page grew to 2400px. The frame is a flex column now with the footer pinned by `margin-top: auto`, and the content slot holds a viewport of height so the footer starts below the fold and only ever moves further out of sight. The slot is `main` once the route renders and `section.ui-page` while Suspense is pending; covering only the first left the shift in place, which is what the intermediate measurements showed. / /explore /projects /releases /search /profile 0.1924 → 0.0001 360 / 768 / 1440 across four routes: no horizontal overflow Separately, the route gate stopped the Studio page but not the Studio shell, so a signed-out visitor who typed /studio still got the whole workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by hiding the contents of a screen while showing the screen. The header is drawn only for an authenticated session; `children` is already the router's sign-in surface, which is the whole of what such a visitor gets. signed out h1 "세션이 필요합니다." 0 nav, 0 studio links signed in h1 "작업 흐름" 2 nav, 8 links, sign-out present Three test updates follow from behaviour that changed rather than broke: the frozen route inventory now derives `access` from each route's own layoutGroup instead of asserting "public" for all 28 — so a Studio route added without a gate fails that table too — and the router and shell harnesses supply the session the Studio surface now reads. The router suite also gains a test that a signed-out visitor gets neither the Studio heading nor its navigation, which is the regression the gate exists for. test:all is 1811 passed; the eight remaining failures are the three load-dependent flake families (ci-artifact-contract, provider-guardian-transaction, security-followup), all of which pass in isolation and reference none of the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
createMemoryRouter,
|
|
matchRoutes,
|
|
Outlet,
|
|
RouterProvider,
|
|
} from "react-router-dom";
|
|
|
|
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
|
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
|
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
|
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
|
|
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
|
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
|
|
import {
|
|
AppRouter,
|
|
createGroupedRouteObjects,
|
|
} from "../../src/presentation/routes/app-router.tsx";
|
|
import { createTestApplication } from "../helpers/create-test-application.ts";
|
|
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
|
import {
|
|
ROUTE_CODECS,
|
|
ROUTE_RUNTIME,
|
|
} from "../../src/features/installed-feature-runtimes.tsx";
|
|
import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
|
|
import { SessionProvider } from "../../src/presentation/providers/session-provider.tsx";
|
|
import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx";
|
|
import { useRouteInput } from "../../src/presentation/routes/route-input.tsx";
|
|
|
|
function StudioFallbackFixture() {
|
|
const input = useRouteInput();
|
|
return <h1>{String(input.params["*"])}</h1>;
|
|
}
|
|
|
|
const reviewFixtureCodecs = Object.freeze({
|
|
ReviewFixtureParams: z
|
|
.object({ reviewId: z.string().min(1) })
|
|
.strict(),
|
|
ReviewFixtureSearch: z
|
|
.object({
|
|
filter: z.preprocess(
|
|
(value) => (Array.isArray(value) ? value[0] : value),
|
|
z.string().trim().min(1).optional(),
|
|
),
|
|
})
|
|
.strip(),
|
|
});
|
|
|
|
const groupedFixtureCodecs = Object.freeze({
|
|
none: ROUTE_CODECS.none,
|
|
NotFoundSplat: ROUTE_CODECS.NotFoundSplat,
|
|
});
|
|
|
|
const reviewFixtureRegistry = Object.freeze({
|
|
REVIEW_FIXTURE: Object.freeze({
|
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
routeId: "REVIEW_FIXTURE",
|
|
path: "/review/:reviewId",
|
|
paramsSchema: "ReviewFixtureParams",
|
|
searchSchema: "ReviewFixtureSearch",
|
|
}),
|
|
});
|
|
|
|
function ReviewRouteFixture() {
|
|
const input = useRouteInput<"REVIEW_FIXTURE">();
|
|
expectTypeOf(input.routeId).toEqualTypeOf<"REVIEW_FIXTURE">();
|
|
return (
|
|
<section>
|
|
<h1>{input.routeId}</h1>
|
|
<p data-testid="review-param">{String(input.params.reviewId)}</p>
|
|
<p data-testid="review-search">{JSON.stringify(input.search)}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const reviewFixtureRuntime = Object.freeze({
|
|
REVIEW_FIXTURE: Object.freeze({
|
|
moduleId: "review-fixture",
|
|
Component: ReviewRouteFixture,
|
|
}),
|
|
});
|
|
|
|
function compileTimeGroupedRouteContract() {
|
|
// @ts-expect-error A grouped route composition must provide its codec registry.
|
|
createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"type-test-build",
|
|
);
|
|
return createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"type-test-build",
|
|
reviewFixtureCodecs,
|
|
);
|
|
}
|
|
void compileTimeGroupedRouteContract;
|
|
|
|
/**
|
|
* Studio routes are `session-required`, so a Studio assertion needs a session
|
|
* that says so — with the anonymous adapter the router correctly renders the
|
|
* sign-in surface instead of the page, which is what the gate is for.
|
|
*/
|
|
function createSignedInSessionAdapter() {
|
|
return createExternalAuthSessionAdapter({
|
|
readState: () => "authenticated" as const,
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => ({ headers: {} }),
|
|
recoverSession: async () => "restored" as const,
|
|
notifyUnauthenticated: () => {},
|
|
});
|
|
}
|
|
|
|
function renderRouter(session = createAnonymousSessionAdapter()) {
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
|
return render(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
session,
|
|
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
|
})}
|
|
>
|
|
<AppRouter />
|
|
</ApplicationProvider>,
|
|
);
|
|
}
|
|
|
|
describe("generic application router", () => {
|
|
it("renders and canonicalizes an isolated non-installed route codec contract", async () => {
|
|
expect(ROUTE_REGISTRY).not.toHaveProperty("REVIEW_FIXTURE");
|
|
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureParams");
|
|
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureSearch");
|
|
|
|
const routes = createGroupedRouteObjects(
|
|
reviewFixtureRegistry,
|
|
reviewFixtureRuntime,
|
|
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
|
|
"test-build",
|
|
reviewFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: [
|
|
"/review/non-empty?filter=%20first%20&filter=second&unknown=drop",
|
|
],
|
|
});
|
|
|
|
render(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "REVIEW_FIXTURE" }),
|
|
).toBeVisible();
|
|
expect(screen.getByTestId("review-param")).toHaveTextContent("non-empty");
|
|
expect(screen.getByTestId("review-search")).toHaveTextContent(
|
|
'{"filter":"first"}',
|
|
);
|
|
await waitFor(() =>
|
|
expect(router.state.location).toMatchObject({
|
|
pathname: "/review/non-empty",
|
|
search: "?filter=first",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
|
|
const registry = {
|
|
TECH_LOG_HOME: ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
STUDIO_FIXTURE: {
|
|
...ROUTE_REGISTRY.NOT_FOUND,
|
|
routeId: "STUDIO_FIXTURE",
|
|
path: "/studio/*",
|
|
layoutGroup: "STUDIO" as const,
|
|
},
|
|
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
|
|
};
|
|
const runtime = {
|
|
TECH_LOG_HOME: ROUTE_RUNTIME.TECH_LOG_HOME,
|
|
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
|
|
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
|
|
};
|
|
const routes = createGroupedRouteObjects(
|
|
registry,
|
|
runtime,
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
|
|
expect(routes.map((route) => route.id)).toEqual([
|
|
"STUDIO_LAYOUT",
|
|
"PUBLIC_LAYOUT",
|
|
]);
|
|
expect(
|
|
matchRoutes(routes, "/studio/unknown")?.map((match) => match.route.id),
|
|
).toEqual(["STUDIO_LAYOUT", "STUDIO_FIXTURE"]);
|
|
expect(
|
|
matchRoutes(routes, "/publicly-unknown")?.map((match) => match.route.id),
|
|
).toEqual(["PUBLIC_LAYOUT", "NOT_FOUND"]);
|
|
|
|
const installedRoutes = createGroupedRouteObjects(
|
|
ROUTE_REGISTRY,
|
|
ROUTE_RUNTIME,
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND");
|
|
});
|
|
|
|
it("rejects a grouped registry whose leaf runtime is missing", () => {
|
|
expect(() =>
|
|
createGroupedRouteObjects(
|
|
ROUTE_REGISTRY,
|
|
{},
|
|
{
|
|
PUBLIC: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="studio" />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
),
|
|
).toThrow("Missing route runtime: TECH_LOG_HOME");
|
|
});
|
|
|
|
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
|
|
const routes = createGroupedRouteObjects(
|
|
{
|
|
STUDIO_FIXTURE: {
|
|
...ROUTE_REGISTRY.NOT_FOUND,
|
|
routeId: "STUDIO_FIXTURE",
|
|
path: "/studio/*",
|
|
layoutGroup: "STUDIO",
|
|
},
|
|
},
|
|
{
|
|
STUDIO_FIXTURE: {
|
|
moduleId: "studio-fixture",
|
|
Component: StudioFallbackFixture,
|
|
},
|
|
},
|
|
{
|
|
PUBLIC: <Outlet />,
|
|
STUDIO: (
|
|
<section data-testid="studio-layout">
|
|
<Outlet />
|
|
</section>
|
|
),
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/studio/unknown/path"],
|
|
});
|
|
|
|
render(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
|
|
expect(await screen.findByTestId("studio-layout")).toBeVisible();
|
|
expect(
|
|
await screen.findByRole("heading", { name: "unknown/path" }),
|
|
).toBeVisible();
|
|
expect(router.state.location.pathname).toBe("/studio/unknown/path");
|
|
});
|
|
|
|
it("applies authorization from a non-installed route definition", async () => {
|
|
const routes = createGroupedRouteObjects(
|
|
{
|
|
PROTECTED_FIXTURE: {
|
|
...ROUTE_REGISTRY.TECH_LOG_HOME,
|
|
routeId: "PROTECTED_FIXTURE",
|
|
path: "/private-fixture",
|
|
access: "session-required",
|
|
searchSchema: null,
|
|
},
|
|
},
|
|
{
|
|
PROTECTED_FIXTURE: {
|
|
moduleId: "protected-fixture",
|
|
Component: StudioFallbackFixture,
|
|
},
|
|
},
|
|
{
|
|
PUBLIC: <Outlet />,
|
|
STUDIO: <Outlet />,
|
|
},
|
|
"test-build",
|
|
groupedFixtureCodecs,
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/private-fixture"],
|
|
});
|
|
|
|
render(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "세션이 필요합니다." }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("renders the installed TechLog home in the Public layout", async () => {
|
|
const user = userEvent.setup();
|
|
window.history.pushState({}, "", "/");
|
|
renderRouter();
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "TechLog", level: 1 }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
await user.click(
|
|
within(screen.getByRole("navigation", { name: "주요 탐색" })).getByRole(
|
|
"link",
|
|
{ name: "프로젝트" },
|
|
),
|
|
);
|
|
expect(
|
|
await screen.findByRole("heading", { name: "프로젝트", level: 1 }),
|
|
).toBeVisible();
|
|
expect(window.location.pathname).toBe("/projects");
|
|
});
|
|
|
|
it("keeps the unreachable client-side Public fallback inside its accessible shell", async () => {
|
|
window.history.pushState({}, "", "/missing");
|
|
renderRouter();
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps a signed-out visitor out of the Studio layout entirely", async () => {
|
|
window.history.pushState({}, "", "/studio");
|
|
renderRouter();
|
|
|
|
// Not merely "no data": the Studio surface itself must not mount. Every
|
|
// TechLog route used to register as `access: "public"`, so a signed-out
|
|
// visitor who typed /studio got the shell, the navigation, and the page —
|
|
// and the page then issued Studio API calls.
|
|
await screen.findByRole("heading", { name: /세션|로그인/ });
|
|
expect(
|
|
screen.queryByRole("heading", { name: "작업 흐름" }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Studio 주 탐색" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
|
window.history.pushState({}, "", "/studio");
|
|
renderRouter(createSignedInSessionAdapter());
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "작업 흐름" }),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute(
|
|
"href",
|
|
"/",
|
|
);
|
|
});
|
|
|
|
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
|
window.history.pushState({}, "", "/studio/missing");
|
|
renderRouter(createSignedInSessionAdapter());
|
|
|
|
expect(
|
|
await screen.findByRole("heading", {
|
|
name: "Studio 화면을 찾을 수 없습니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
|
|
expect(window.location.pathname).toBe("/studio/missing");
|
|
});
|
|
});
|