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>
147 lines
6.8 KiB
TypeScript
147 lines
6.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { Outlet, RouterProvider, createMemoryRouter } from "react-router-dom";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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 type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
|
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
|
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
|
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
|
import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
import { SessionProvider } from "../../../src/presentation/providers/session-provider.tsx";
|
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
function renderStudio(
|
|
initialEntry: string,
|
|
createStudioGateway: () => StudioGateway,
|
|
) {
|
|
const router = createMemoryRouter(
|
|
[
|
|
{
|
|
path: "/studio",
|
|
element: (
|
|
<StudioShell>
|
|
<Outlet />
|
|
</StudioShell>
|
|
),
|
|
children: [
|
|
{ index: true, element: <StudioHomePage /> },
|
|
{ path: "documents", element: <p>작업본 라우트</p> },
|
|
{ path: "documents/new", element: <p>새 문서 라우트</p> },
|
|
{ path: "publications", element: <p>게시 기록 라우트</p> },
|
|
{ path: "*", element: <StudioNotFoundPage /> },
|
|
],
|
|
},
|
|
],
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const application = createTestApplication({
|
|
// The Studio shell draws its chrome only for an authenticated session — a
|
|
// signed-out visitor gets the sign-in surface and nothing else, which is
|
|
// what these tests are not about. In the real app AppRouter supplies both
|
|
// the session state and the provider; here the harness does.
|
|
session: createExternalAuthSessionAdapter({
|
|
readState: () => "authenticated" as const,
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => ({ headers: {} }),
|
|
recoverSession: async () => "restored" as const,
|
|
notifyUnauthenticated: () => {},
|
|
}),
|
|
featureInputs: {
|
|
"tech-log": { ...installed, createStudioGateway },
|
|
},
|
|
});
|
|
const view = render(
|
|
<ApplicationProvider application={application}>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
return { ...view, router };
|
|
}
|
|
|
|
describe("TechLog Studio shell", () => {
|
|
it("creates one application-provided gateway for child navigation and exposes the source header", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const createGateway = vi.fn(() => gateway);
|
|
|
|
const { container, router } = renderStudio("/studio", createGateway);
|
|
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
Array.from(
|
|
container.querySelectorAll<HTMLAnchorElement>(".studio-desktop-navigation a"),
|
|
(link) => [link.textContent, link.getAttribute("href")],
|
|
),
|
|
).toEqual([
|
|
["작업본", "/studio/documents"],
|
|
["게시 기록", "/studio/publications"],
|
|
["새 문서", "/studio/documents/new"],
|
|
["공개 사이트 보기", "/"],
|
|
]);
|
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
|
|
await user.click(within(screen.getByRole("navigation", { name: "Studio 주 탐색" })).getByRole("link", { name: "작업본" }));
|
|
|
|
expect(router.state.location.pathname).toBe("/studio/documents");
|
|
expect(screen.getByText("작업본 라우트")).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(screen.getAllByRole("link", { name: "작업본" })[0]).toHaveAttribute("aria-current", "page");
|
|
});
|
|
|
|
it("recreates the provider generation and cancels obsolete work after a persisted pageshow", async () => {
|
|
const user = userEvent.setup();
|
|
let firstSignal: AbortSignal | undefined;
|
|
const first = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const second = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
vi.spyOn(first, "getDashboard").mockImplementation(({ signal } = {}) => {
|
|
firstSignal = signal;
|
|
return new Promise(() => {});
|
|
});
|
|
const secondDashboard = vi.spyOn(second, "getDashboard");
|
|
const createGateway = vi.fn()
|
|
.mockReturnValueOnce(first)
|
|
.mockReturnValueOnce(second);
|
|
|
|
renderStudio("/studio", createGateway);
|
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
|
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
|
|
|
|
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
|
|
|
await waitFor(() => expect(createGateway).toHaveBeenCalledTimes(2));
|
|
expect(firstSignal?.aborted).toBe(true);
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 열기" })).toHaveAttribute("aria-expanded", "false");
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(secondDashboard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps unknown Studio routes inside the Studio shell without authentication UI", () => {
|
|
const createGateway = vi.fn(
|
|
() => createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
|
|
);
|
|
|
|
renderStudio("/studio/does-not-exist", createGateway);
|
|
|
|
expect(screen.getByRole("banner")).toHaveClass("studio-header");
|
|
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute("href", "/studio/documents");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|