src/app/page-frames.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { NO_CHROME_ROUTES } from "@/components/layout/app-shell";
import { isFormPath, labelForPath } from "@/lib/return-to";
import {
drawFrame,
frameMocks as stable,
resetFrameMocks,
} from "@/test/page-frame";
// What every destination owes: its *own* first render is the frame - the heading, the
// chrome and the placeholders - rather than a spinner or an empty column. Nothing else in
// the suite can see this. A page that early-returned a `Loader2` while its request was in
// flight would pass every other test in the repo and be the one thing a diver notices,
// because a click into it would go back to doing nothing until the data landed.
//
// The pages are rendered with their auth settled and every request left in flight, which
// is what a soft navigation into them looks like. One mock does the second half: the
// axios instance underneath `lib/api/` answers nothing, so every page and card sits in
// the state it arrives in.
//
// The set is derived from the route tree below rather than listed, so a new route inside
// the chrome fails here rather than being noticed months later. The admin section's
// screens are checked elsewhere - each in its own directory, since nothing outside the
// section may import it (`lib/admin-isolation.test.ts`).
vi.mock("next/navigation", async () => {
const { frameMocks } = await import("@/test/page-frame");
return {
usePathname: () => frameMocks.at.pathname,
useRouter: () => frameMocks.router,
useParams: () => frameMocks.params,
useSearchParams: () => frameMocks.searchParams,
useSelectedLayoutSegments: () => [],
redirect: vi.fn(),
};
});
vi.mock("@/contexts/AuthContext", async () => {
const { frameMocks } = await import("@/test/page-frame");
return { useAuth: () => frameMocks.auth };
});
// Lives in `AppShell`, above the `<main>` every page here renders into, so it is out of
// the shape being inspected and in the way of rendering it.
vi.mock("@/components/layout/quick-create", async () => {
const { frameMocks } = await import("@/test/page-frame");
return { useQuickCreate: () => frameMocks.openCreate };
});
vi.mock("@/components/ui/use-toast", async (importOriginal) => {
const { frameMocks } = await import("@/test/page-frame");
return {
...(await importOriginal<object>()),
useToast: () => frameMocks.toast,
};
});
vi.mock("@/lib/api/client", async (importOriginal) => {
const { frameMocks } = await import("@/test/page-frame");
return {
...(await importOriginal<object>()),
apiClient: frameMocks.apiClient,
};
});
// Every destination, its route pattern - which is what the derivation below matches
// against - and the module that answers for it. A route with no row is a destination
// nobody has said what to draw for.
const DESTINATIONS = [
{
route: "/home",
path: "/home",
page: () => import("./home/page"),
},
{ route: "/dives", path: "/dives", page: () => import("./dives/page") },
{
route: "/dives/[id]",
path: "/dives/dive-1",
// The `(detail)` layout owns the dive fetch and draws the skeleton; the page inside
// it renders nothing until the dive is there.
page: () => import("./dives/(detail)/layout"),
params: { id: "dive-1" },
pageProps: { children: null },
},
{
route: "/dives/[id]/edit",
path: "/dives/dive-1/edit",
page: () => import("./dives/[id]/edit/page"),
params: { id: "dive-1" },
},
{ route: "/trips", path: "/trips", page: () => import("./trips/page") },
{
route: "/trips/[id]",
path: "/trips/trip-1",
page: () => import("./trips/[id]/page"),
params: { id: "trip-1" },
},
{ route: "/sites", path: "/sites", page: () => import("./sites/page") },
{
route: "/sites/proposals",
path: "/sites/proposals",
page: () => import("./sites/proposals/page"),
},
{
route: "/sites/[id]",
path: "/sites/site-1",
page: () => import("./sites/[id]/page"),
params: { id: "site-1" },
},
{ route: "/courses", path: "/courses", page: () => import("./courses/page") },
{
route: "/courses/[id]",
path: "/courses/course-1",
page: () => import("./courses/[id]/page"),
params: { id: "course-1" },
},
{
route: "/contacts",
path: "/contacts",
page: () => import("./contacts/page"),
},
{ route: "/people", path: "/people", page: () => import("./people/page") },
{
route: "/people/[id]",
path: "/people/person-1",
page: () => import("./people/[id]/page"),
params: { id: "person-1" },
},
{ route: "/species", path: "/species", page: () => import("./species/page") },
{
route: "/species/[id]",
path: "/species/species-1",
page: () => import("./species/[id]/page"),
params: { id: "species-1" },
},
{ route: "/gear", path: "/gear", page: () => import("./gear/page") },
{
route: "/gear/[id]",
path: "/gear/gear-1",
page: () => import("./gear/[id]/page"),
params: { id: "gear-1" },
},
{
route: "/certifications",
path: "/certifications",
page: () => import("./certifications/page"),
},
{ route: "/checkin", path: "/checkin", page: () => import("./checkin/page") },
{ route: "/import", path: "/import", page: () => import("./import/page") },
{
route: "/import/[id]",
path: "/import/import-1",
page: () => import("./import/[id]/page"),
params: { id: "import-1" },
},
{
route: "/import/[id]/log",
path: "/import/import-1/log",
page: () => import("./import/[id]/log/page"),
params: { id: "import-1" },
},
];
// ------------------------------------------------------------------ the derivation
const APP = __dirname;
function walk(dir: string, found: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, found);
else found.push(full);
}
return found;
}
const files = walk(APP);
const rel = (file: string) =>
path.relative(APP, file).split(path.sep).join("/");
// A route path, with route groups dropped the way the router drops them.
const routeOf = (file: string) =>
"/" +
rel(file)
.replace(/\/?page\.tsx$/, "")
.split("/")
.filter((segment) => segment && !segment.startsWith("("))
.join("/");
// A Client Component, or a Server Component only so it can carry `metadata` and render its
// client body from `components/` (see "Metadata, and why every page is a Server Component"
// in DECISIONS.md), or `instant = false` below an auth gate (see "A page under an auth-gate
// layout opts out of instant validation").
const isClientDrawn = (file: string) => {
const source = readFileSync(file, "utf8");
return (
source.startsWith('"use client"') ||
/^export const instant = false;$/m.test(source) ||
/from "@\/components\/[\w/-]+-page-content";$/m.test(source)
);
};
// A destination is a page a signed-in diver navigates to inside the app chrome. The other
// server-rendered pages are the public ones and the `/settings` redirect; the chrome-free
// ones are reached from an email link or a redirect and draw their own layout.
const destinations = files
.filter((file) => file.endsWith("/page.tsx"))
.filter(isClientDrawn)
.map(routeOf)
.filter(
(route) =>
!NO_CHROME_ROUTES.some(
(free) => route === free || route.startsWith(`${free}/`),
),
);
// These destinations draw no frame, each for a reason of its own rather than by oversight:
// the `/settings` sections render a spinner on their auth bootstrap and wait on no data of
// their own, so a frame there would draw grey where none is drawn, and `/dives/new` renders
// its real form as soon as that same bootstrap settles.
const NO_FRAME = ["/settings", "/dives/new"];
// Each admin screen draws one, checked inside `app/admin` because nothing out here may
// import that section (`lib/admin-isolation.test.ts`).
const framedInsideAdmin = (route: string) =>
route === "/admin" || route.startsWith("/admin/");
beforeEach(resetFrameMocks);
describe("the destination set", () => {
it("is covered, route for route", () => {
const covered = new Set(
DESTINATIONS.map((destination) => destination.route),
);
const owed = destinations
.filter(
(route) =>
!NO_FRAME.some(
(free) => route === free || route.startsWith(`${free}/`),
),
)
.filter((route) => !framedInsideAdmin(route));
expect(owed.filter((route) => !covered.has(route))).toEqual([]);
// A floor, so the derivation failing open is itself a failure.
expect(owed.length).toBeGreaterThan(10);
});
it("has no loading boundary left anywhere under app", () => {
// The frame is the page's own first render now, so a `loading.tsx` would be a second
// renderer of it - and a Suspense fallback, which is what holds the page behind it
// for ~300ms once it commits.
expect(files.filter((file) => file.endsWith("/loading.tsx"))).toEqual([]);
});
});
describe("every destination's first render is its own frame", () => {
it.each(DESTINATIONS.map((d) => [d.path, d] as const))(
"%s",
async (route, destination) => {
stable.params = destination.params ?? {};
const { container } = await drawFrame(
route,
destination.page,
destination.pageProps ?? {},
);
// A heading and placeholders, which is what makes this a frame rather than a
// spinner or an empty column.
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector(".animate-spin")).toBeNull();
// A region that says it is busy, rather than a screen of boxes that say nothing.
expect(container.querySelector("[aria-busy='true']")).toBeInTheDocument();
// Every bar is out of the accessibility tree: a reader taken through a dozen
// empty boxes is worse off than one told nothing at all.
const bars = container.querySelectorAll(".animate-skeleton");
expect(bars.length).toBeGreaterThan(0);
bars.forEach((bar) => expect(bar).toHaveAttribute("aria-hidden", "true"));
// And no placeholder row is announced as a row.
const rows = screen.queryAllByRole("row");
rows.forEach((row) =>
expect(within(row).queryByTestId("skeleton")).toBeNull(),
);
rows.forEach((row) =>
expect(row.querySelector(".animate-skeleton")).toBeNull(),
);
// Where the title is a placeholder it stands *inside* the heading, which is what
// keeps the header the same height before and after the record lands.
const heading = container.querySelector("h1");
if (heading?.textContent === "") {
expect(heading.querySelector(".animate-skeleton")).toBeInTheDocument();
}
// A diver who has asked not to be animated at sees the frame - grey and all - at
// the click, with no delay. That is accepted rather than fixed, and asserted so it
// cannot change in silence. jsdom cannot evaluate the media query, so what is
// pinned is the class that carries it.
const animated = container.querySelectorAll(
".animate-skeleton, .animate-skeleton-reveal",
);
expect(animated.length).toBeGreaterThan(0);
animated.forEach((node) =>
expect(node.className).toContain("motion-reduce:animate-none"),
);
},
30_000,
);
});
// The form pages and the record pages take their back link from the URL, through
// `useReturnTo`, which reads `useSearchParams()` with no Suspense boundary above it.
const backLink = (container: HTMLElement) => {
const link = container.querySelector("a");
return { href: link?.getAttribute("href"), label: link?.textContent };
};
// A record is opened from all over - a dive from a trip, a trip from the Home page - so
// each record page's back link, drawn before its record arrives, goes where it was
// opened from.
describe("a record page's back link", () => {
it.each(
DESTINATIONS.filter((d) => d.route.endsWith("/[id]")).map(
(d) => [d.path, d] as const,
),
)("%s", async (route, destination) => {
stable.params = destination.params ?? {};
stable.searchParams = new URLSearchParams("from=/home");
const { container } = await drawFrame(
route,
destination.page,
destination.pageProps ?? {},
);
expect(backLink(container)).toEqual({
href: "/home",
label: "Back to home",
});
});
});
describe("/dives/new", () => {
it.each([
["nothing in the URL", new URLSearchParams(), "/dives", "Back to dives"],
["?from=/home", new URLSearchParams("from=/home"), "/home", "Back to home"],
[
"?from=/people",
new URLSearchParams("from=/people"),
"/people",
"Back to people",
],
[
"?trip_uuid=",
new URLSearchParams("trip_uuid=trip-1"),
"/trips/trip-1",
"Back to trip",
],
])("takes its back link from %s", async (_name, params, href, label) => {
stable.searchParams = params;
const { container } = await drawFrame(
"/dives/new",
() => import("./dives/new/page"),
);
expect(backLink(container)).toEqual({ href, label });
});
// The header's create menu hands the form whichever page it was opened from, so every
// page inside the chrome is a possible `?from=` - including the public ones a signed-in
// diver can read, but not the landing pages, which redirect a signed-in visitor away.
it("names every page it can be opened from", () => {
const SIGNED_OUT_ONLY = ["/", "/join"];
const openedFrom = files
.filter((file) => file.endsWith("/page.tsx"))
.map(routeOf)
.filter(
(route) =>
!SIGNED_OUT_ONLY.includes(route) &&
!isFormPath(route) &&
!NO_CHROME_ROUTES.some(
(free) => route === free || route.startsWith(`${free}/`),
),
);
expect(
openedFrom.filter((route) => labelForPath(route) === "Back"),
).toEqual([]);
expect(openedFrom.length).toBeGreaterThan(20);
});
it("draws its form rather than a spinner", async () => {
// The one destination with no pre-data render of its own: it renders the real form as
// soon as the auth check settles, which is why it is out of the frame set above.
const { container } = await drawFrame(
"/dives/new",
() => import("./dives/new/page"),
);
expect(container.querySelector(".animate-spin")).toBeNull();
expect(container.querySelector("form")).not.toBeNull();
});
});
describe("the edit form's back link", () => {
it.each([
["nothing in the URL", new URLSearchParams(), "/dives/dive-1"],
["?from=/dives", new URLSearchParams("from=/dives"), "/dives"],
["?trip_uuid=", new URLSearchParams("trip_uuid=trip-1"), "/trips/trip-1"],
])("%s", async (_name, params, href) => {
stable.params = { id: "dive-1" };
stable.searchParams = params;
const { container } = await drawFrame(
"/dives/dive-1/edit",
() => import("./dives/[id]/edit/page"),
);
expect(backLink(container).href).toBe(href);
});
});