mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1dbcbfbcc8
* feat(app-router): plan navigation from RouteManifest topology Navigation commit approval still trusted flight payload snapshots for root-boundary and target-slot topology even when the static RouteManifest existed. That made stale or missing snapshot metadata authoritative during App Router commits, which could preserve layouts across the wrong root or fall back to soft commits when the graph already proved a same-root transition. The planner now resolves current and target topology through RouteManifest when available, uses snapshot slot bindings only as visible content proof, and keeps explicit interception proof as the authority for intercepted slot commits. The generated browser entry embeds the manifest and approval paths pass it through to the planner. Targeted coverage exercises stale snapshot roots, missing snapshot roots, dynamic URL pattern resolution, manifest target slot bindings, visible-slot proof, intercepted source topology, and browser-entry manifest serialization. * fix(app-router): read route manifest lazily during navigation approval The browser entry assigned the generated RouteManifest global before a static import, but ESM evaluates imported modules before the importer body. That let the browser navigation controller capture null at module evaluation, leaving manifest-aware planning disabled in real browser bootstraps. Make the controller accept a lazy RouteManifest getter and read it at commit-planning time. Also require direct manifest route-id hits to match the snapshot matchedUrl before trusting them, so stale-but-valid route ids fall back to concrete URL pattern resolution. Covered by app browser controller and navigation planner regressions, plus targeted entry template checks. * fix(app-router): clarify manifest route topology resolution Manifest route lookup now shares one matched-url normalization helper across direct route checks and pattern fallback. That keeps concrete dynamic URL fallback from drifting across call sites. Document that manifest pattern iteration follows buildAppRouteGraph route precedence, and record why intercepted targets cannot use direct-target manifest slot bindings.
659 lines
25 KiB
TypeScript
659 lines
25 KiB
TypeScript
/**
|
|
* Behavioral tests for the App Router entry-template code generators.
|
|
*
|
|
* Tests focus on observable behavior (structured API outputs and error paths),
|
|
* not on the textual shape of the generated code.
|
|
*/
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import { describe, it, expect } from "vite-plus/test";
|
|
import { generateBrowserEntry } from "../packages/vinext/src/entries/app-browser-entry.js";
|
|
import { buildAppRscManifestCode } from "../packages/vinext/src/entries/app-rsc-manifest.js";
|
|
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
|
|
import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js";
|
|
import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js";
|
|
import { buildAppRouteGraph } from "../packages/vinext/src/routing/app-route-graph.js";
|
|
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
|
|
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
|
|
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
|
|
|
|
// ── Minimal App Router route fixtures ─────────────────────────────────
|
|
// Use stable absolute paths so tests don't depend on the machine.
|
|
const minimalAppRoutes: AppRoute[] = [
|
|
{
|
|
pattern: "/",
|
|
patternParts: [],
|
|
pagePath: "/tmp/test/app/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null],
|
|
notFoundPath: null,
|
|
notFoundPaths: [null],
|
|
forbiddenPaths: [null],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [null],
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
{
|
|
pattern: "/about",
|
|
patternParts: ["about"],
|
|
pagePath: "/tmp/test/app/about/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null],
|
|
notFoundPath: null,
|
|
notFoundPaths: [null],
|
|
forbiddenPaths: [null],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [null],
|
|
unauthorizedPath: null,
|
|
routeSegments: ["about"],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
{
|
|
pattern: "/blog/:slug",
|
|
patternParts: ["blog", ":slug"],
|
|
pagePath: "/tmp/test/app/blog/[slug]/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/blog/[slug]/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null, null],
|
|
notFoundPath: null,
|
|
notFoundPaths: [null, null],
|
|
forbiddenPaths: [null, null],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [null, null],
|
|
unauthorizedPath: null,
|
|
routeSegments: ["blog", ":slug"],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0, 1],
|
|
isDynamic: true,
|
|
params: ["slug"],
|
|
},
|
|
{
|
|
pattern: "/dashboard",
|
|
patternParts: ["dashboard"],
|
|
pagePath: "/tmp/test/app/dashboard/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/dashboard/layout.tsx"],
|
|
templates: ["/tmp/test/app/dashboard/template.tsx"],
|
|
parallelSlots: [],
|
|
loadingPath: "/tmp/test/app/dashboard/loading.tsx",
|
|
errorPath: "/tmp/test/app/dashboard/error.tsx",
|
|
layoutErrorPaths: [null, "/tmp/test/app/dashboard/error.tsx"],
|
|
notFoundPath: "/tmp/test/app/dashboard/not-found.tsx",
|
|
notFoundPaths: [null, "/tmp/test/app/dashboard/not-found.tsx"],
|
|
forbiddenPaths: [null, "/tmp/test/app/dashboard/forbidden.tsx"],
|
|
forbiddenPath: "/tmp/test/app/dashboard/forbidden.tsx",
|
|
unauthorizedPaths: [null, "/tmp/test/app/dashboard/unauthorized.tsx"],
|
|
unauthorizedPath: "/tmp/test/app/dashboard/unauthorized.tsx",
|
|
routeSegments: ["dashboard"],
|
|
templateTreePositions: [1],
|
|
layoutTreePositions: [0, 1],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
];
|
|
|
|
// ── App Router manifest construction ─────────────────────────────────
|
|
|
|
describe("App Router generated manifest construction", () => {
|
|
it("embeds the Link auto-prefetch route manifest in the browser entry", () => {
|
|
const code = generateBrowserEntry([
|
|
...minimalAppRoutes,
|
|
{
|
|
pattern: "/modal-host",
|
|
patternParts: ["modal-host"],
|
|
pagePath: null,
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/modal-host/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null, null],
|
|
notFoundPath: null,
|
|
notFoundPaths: [null, null],
|
|
forbiddenPaths: [null, null],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [null, null],
|
|
unauthorizedPath: null,
|
|
routeSegments: ["modal-host"],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0, 1],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
{
|
|
pattern: "/api",
|
|
patternParts: ["api"],
|
|
pagePath: null,
|
|
routePath: "/tmp/test/app/api/route.ts",
|
|
layouts: [],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [],
|
|
notFoundPath: null,
|
|
notFoundPaths: [],
|
|
forbiddenPaths: [],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [],
|
|
unauthorizedPath: null,
|
|
routeSegments: ["api"],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
]);
|
|
|
|
expect(code).toContain("window.__VINEXT_LINK_PREFETCH_ROUTES__ = ");
|
|
expect(code).toContain("window.__VINEXT_ROUTE_MANIFEST__ = null;");
|
|
expect(code).toContain('{"patternParts":["about"],"isDynamic":false}');
|
|
expect(code).toContain('{"patternParts":["blog",":slug"],"isDynamic":true}');
|
|
expect(code).toContain('{"patternParts":["modal-host"],"isDynamic":false}');
|
|
expect(code).not.toContain('{"patternParts":["api"],"isDynamic":false}');
|
|
});
|
|
|
|
it("embeds the RouteManifest read model in the browser entry", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-browser-route-manifest-"));
|
|
const appDir = path.join(tmpDir, "app");
|
|
try {
|
|
fs.mkdirSync(path.join(appDir, "dashboard"), { recursive: true });
|
|
fs.writeFileSync(path.join(appDir, "layout.tsx"), "export default function Layout() {}\n");
|
|
fs.writeFileSync(path.join(appDir, "page.tsx"), "export default function Page() {}\n");
|
|
fs.writeFileSync(
|
|
path.join(appDir, "dashboard", "page.tsx"),
|
|
"export default function Page() {}\n",
|
|
);
|
|
|
|
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
|
|
const code = generateBrowserEntry(graph.routes, graph.routeManifest);
|
|
|
|
expect(code).toContain("window.__VINEXT_ROUTE_MANIFEST__ = {");
|
|
expect(code).toContain("graphVersion:");
|
|
expect(code).toContain("routes: new Map(");
|
|
expect(code).toContain("rootBoundaries: new Map(");
|
|
expect(code).toContain('"route:/dashboard"');
|
|
expect(code).toContain('"root-boundary:/"');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("constructs route module imports and route entries from the scanned app shape", () => {
|
|
const routes = [
|
|
{
|
|
pattern: "/",
|
|
patternParts: [],
|
|
pagePath: "/tmp/test/app/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null],
|
|
notFoundPath: "/tmp/test/app/not-found.tsx",
|
|
notFoundPaths: ["/tmp/test/app/not-found.tsx"],
|
|
forbiddenPath: "/tmp/test/app/forbidden.tsx",
|
|
forbiddenPaths: ["/tmp/test/app/forbidden.tsx"],
|
|
unauthorizedPath: "/tmp/test/app/unauthorized.tsx",
|
|
unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx"],
|
|
routeSegments: [],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
{
|
|
ids: {
|
|
route: "route:/dashboard/:id",
|
|
page: "page:/dashboard/:id",
|
|
routeHandler: "route-handler:/dashboard/:id",
|
|
rootBoundary: "root-boundary:/",
|
|
layouts: ["layout:/", "layout:/dashboard"],
|
|
templates: ["template:/dashboard"],
|
|
slots: {
|
|
"modal:/tmp/test/app/dashboard/@modal": "slot:modal:/dashboard",
|
|
},
|
|
},
|
|
pattern: "/dashboard/:id",
|
|
patternParts: ["dashboard", ":id"],
|
|
pagePath: "/tmp/test/app/dashboard/[id]/page.tsx",
|
|
routePath: "/tmp/test/app/dashboard/[id]/route.ts",
|
|
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/dashboard/layout.tsx"],
|
|
templates: ["/tmp/test/app/dashboard/template.tsx"],
|
|
parallelSlots: [
|
|
{
|
|
id: "slot:modal:/dashboard",
|
|
key: "modal:/tmp/test/app/dashboard/@modal",
|
|
name: "modal",
|
|
ownerDir: "/tmp/test/app/dashboard/@modal",
|
|
ownerTreePath: "/dashboard",
|
|
hasPage: true,
|
|
pagePath: "/tmp/test/app/dashboard/@modal/page.tsx",
|
|
defaultPath: "/tmp/test/app/dashboard/@modal/default.tsx",
|
|
layoutPath: "/tmp/test/app/dashboard/@modal/layout.tsx",
|
|
loadingPath: "/tmp/test/app/dashboard/@modal/loading.tsx",
|
|
errorPath: "/tmp/test/app/dashboard/@modal/error.tsx",
|
|
interceptingRoutes: [
|
|
{
|
|
convention: ".",
|
|
targetPattern: "/photos/:photoId",
|
|
sourceMatchPattern: "/dashboard",
|
|
pagePath: "/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx",
|
|
layoutPaths: ["/tmp/test/app/dashboard/@modal/(.)photos/layout.tsx"],
|
|
params: ["photoId"],
|
|
},
|
|
],
|
|
layoutIndex: 1,
|
|
routeSegments: ["@modal"],
|
|
},
|
|
],
|
|
loadingPath: "/tmp/test/app/dashboard/loading.tsx",
|
|
errorPath: "/tmp/test/app/dashboard/error.tsx",
|
|
layoutErrorPaths: [null, "/tmp/test/app/dashboard/error.tsx"],
|
|
notFoundPath: "/tmp/test/app/dashboard/not-found.tsx",
|
|
notFoundPaths: ["/tmp/test/app/not-found.tsx", "/tmp/test/app/dashboard/not-found.tsx"],
|
|
forbiddenPath: null,
|
|
forbiddenPaths: ["/tmp/test/app/forbidden.tsx", null],
|
|
unauthorizedPath: null,
|
|
unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx", null],
|
|
routeSegments: ["dashboard", "[id]"],
|
|
templateTreePositions: [1],
|
|
layoutTreePositions: [0, 1],
|
|
isDynamic: true,
|
|
params: ["id"],
|
|
rootParamNames: ["id"],
|
|
},
|
|
] satisfies AppRoute[];
|
|
|
|
const manifest = buildAppRscManifestCode({
|
|
routes,
|
|
metadataRoutes: [],
|
|
globalErrorPath: "/tmp/test/app/global-error.tsx",
|
|
});
|
|
|
|
const imports = manifest.imports.join("\n");
|
|
expect(imports.match(/\/tmp\/test\/app\/layout\.tsx/g)).toHaveLength(1);
|
|
expect(imports).toContain('import * as mod_0 from "/tmp/test/app/page.tsx";');
|
|
expect(imports).toContain(
|
|
'import * as mod_17 from "/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx";',
|
|
);
|
|
expect(imports).toContain('import * as mod_19 from "/tmp/test/app/global-error.tsx";');
|
|
|
|
expect(manifest.rootNotFoundVar).toBe("mod_2");
|
|
expect(manifest.rootForbiddenVar).toBe("mod_3");
|
|
expect(manifest.rootUnauthorizedVar).toBe("mod_4");
|
|
expect(manifest.rootLayoutVars).toEqual(["mod_1"]);
|
|
expect(manifest.globalErrorVar).toBe("mod_19");
|
|
|
|
const dynamicRouteEntry = manifest.routeEntries[1];
|
|
expect(dynamicRouteEntry).toContain('"route":"route:/dashboard/:id"');
|
|
expect(dynamicRouteEntry).toContain(
|
|
'"modal:/tmp/test/app/dashboard/@modal":"slot:modal:/dashboard"',
|
|
);
|
|
expect(dynamicRouteEntry).toContain('id: "slot:modal:/dashboard"');
|
|
expect(dynamicRouteEntry).toContain('pattern: "/dashboard/:id"');
|
|
expect(dynamicRouteEntry).toContain("routeHandler: mod_6");
|
|
expect(dynamicRouteEntry).toContain("layouts: [mod_1, mod_7]");
|
|
expect(dynamicRouteEntry).toContain('"modal:/tmp/test/app/dashboard/@modal": {');
|
|
expect(dynamicRouteEntry).toContain("interceptLayouts: [mod_18]");
|
|
expect(dynamicRouteEntry).toContain("page: mod_17");
|
|
expect(dynamicRouteEntry).toContain('params: ["photoId"]');
|
|
expect(manifest.generateStaticParamsEntries).toEqual([
|
|
' "/dashboard/:id": mod_5?.generateStaticParams ?? null,',
|
|
]);
|
|
});
|
|
|
|
it("imports the global-not-found module and exposes its var when provided", () => {
|
|
// Mirrors how vinext scans `app/global-not-found.tsx` in
|
|
// packages/vinext/src/index.ts and threads it into the manifest so the
|
|
// generated RSC entry can hand it to createAppFallbackRenderer.
|
|
// See https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
|
|
const manifest = buildAppRscManifestCode({
|
|
routes: minimalAppRoutes,
|
|
metadataRoutes: [],
|
|
globalErrorPath: null,
|
|
globalNotFoundPath: "/tmp/test/app/global-not-found.tsx",
|
|
});
|
|
|
|
const imports = manifest.imports.join("\n");
|
|
expect(imports).toContain('from "/tmp/test/app/global-not-found.tsx"');
|
|
expect(manifest.globalNotFoundVar).toBeTruthy();
|
|
});
|
|
|
|
it("does not import a global-not-found module when the path is absent", () => {
|
|
const manifest = buildAppRscManifestCode({
|
|
routes: minimalAppRoutes,
|
|
metadataRoutes: [],
|
|
globalErrorPath: null,
|
|
globalNotFoundPath: null,
|
|
});
|
|
|
|
expect(manifest.imports.join("\n")).not.toContain("global-not-found");
|
|
expect(manifest.globalNotFoundVar).toBeNull();
|
|
});
|
|
|
|
it("serializes graph-minted ids without leaking the filesystem root", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-manifest-"));
|
|
const appDir = path.join(tmpDir, "app");
|
|
try {
|
|
fs.mkdirSync(path.join(appDir, "(marketing)", "blog", "[slug]", "@modal"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(path.join(appDir, "layout.tsx"), "export default function Layout() {}\n");
|
|
fs.writeFileSync(
|
|
path.join(appDir, "(marketing)", "layout.tsx"),
|
|
"export default function Layout() {}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(appDir, "(marketing)", "blog", "[slug]", "layout.tsx"),
|
|
"export default function Layout() {}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(appDir, "(marketing)", "blog", "[slug]", "template.tsx"),
|
|
"export default function Template() {}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(appDir, "(marketing)", "blog", "[slug]", "page.tsx"),
|
|
"export default function Page() {}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(appDir, "(marketing)", "blog", "[slug]", "@modal", "default.tsx"),
|
|
"export default function Default() {}\n",
|
|
);
|
|
|
|
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
|
|
const manifest = buildAppRscManifestCode({
|
|
routes: graph.routes,
|
|
metadataRoutes: [],
|
|
globalErrorPath: null,
|
|
});
|
|
|
|
const routeEntry = manifest.routeEntries.find((entry) =>
|
|
entry.includes('pattern: "/blog/:slug"'),
|
|
);
|
|
|
|
expect(routeEntry).toBeDefined();
|
|
expect(routeEntry).not.toContain(appDir);
|
|
expect(routeEntry).toContain('"route":"route:/blog/:slug"');
|
|
expect(routeEntry).toContain('"page":"page:/blog/:slug"');
|
|
expect(routeEntry).toContain('"layout:/(marketing)/blog/[slug]"');
|
|
expect(routeEntry).toContain('"template:/(marketing)/blog/[slug]"');
|
|
expect(routeEntry).toContain(
|
|
'"modal@(marketing)/blog/[slug]/@modal":"slot:modal:/(marketing)/blog/[slug]"',
|
|
);
|
|
expect(routeEntry).toContain('id: "slot:modal:/(marketing)/blog/[slug]"');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("embeds static metadata files and imports dynamic metadata modules", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-manifest-"));
|
|
try {
|
|
const staticManifestPath = path.join(tmpDir, "manifest.webmanifest");
|
|
const dynamicOgPath = path.join(tmpDir, "blog", "[slug]", "opengraph-image.tsx");
|
|
fs.mkdirSync(path.dirname(dynamicOgPath), { recursive: true });
|
|
fs.writeFileSync(staticManifestPath, '{"name":"Vinext"}');
|
|
fs.writeFileSync(dynamicOgPath, "export default function Image() {}");
|
|
|
|
const manifest = buildAppRscManifestCode({
|
|
routes: minimalAppRoutes,
|
|
metadataRoutes: [
|
|
{
|
|
type: "manifest",
|
|
isDynamic: false,
|
|
filePath: staticManifestPath,
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/manifest.webmanifest",
|
|
contentType: "application/manifest+json",
|
|
},
|
|
{
|
|
type: "opengraph-image",
|
|
isDynamic: true,
|
|
filePath: dynamicOgPath,
|
|
routePrefix: "/blog/[slug]",
|
|
routeSegments: ["blog", "[slug]"],
|
|
servedUrl: "/blog/[slug]/opengraph-image",
|
|
contentType: "image/png",
|
|
},
|
|
],
|
|
globalErrorPath: null,
|
|
});
|
|
|
|
const entries = manifest.metaRouteEntries.join("\n");
|
|
expect(entries).toContain(
|
|
`fileDataBase64: ${JSON.stringify(Buffer.from('{"name":"Vinext"}').toString("base64"))}`,
|
|
);
|
|
// Dynamic metadata modules get imported and referenced with a generated name
|
|
expect(entries).toMatch(/module: mod_\d+/);
|
|
expect(manifest.imports.some((imp) => imp.includes("opengraph-image.tsx"))).toBe(true);
|
|
expect(entries).toContain('patternParts: ["blog",":slug","opengraph-image"]');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("throws a build-time error when a discovered static metadata file cannot be read", () => {
|
|
expect(() =>
|
|
buildAppRscManifestCode({
|
|
routes: minimalAppRoutes,
|
|
metadataRoutes: [
|
|
{
|
|
type: "manifest",
|
|
isDynamic: false,
|
|
filePath: "/tmp/test/app/missing-manifest.webmanifest",
|
|
routePrefix: "",
|
|
routeSegments: [],
|
|
servedUrl: "/manifest.webmanifest",
|
|
contentType: "application/manifest+json",
|
|
},
|
|
],
|
|
globalErrorPath: null,
|
|
}),
|
|
).toThrow("[vinext] Failed to read metadata route file");
|
|
});
|
|
});
|
|
|
|
// ── App Router entry template error paths ────────────────────────────
|
|
|
|
describe("App Router entry templates", () => {
|
|
it("installs server globals before App Router user modules are imported", () => {
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
|
|
const globalsImportIndex = code.indexOf("/server-globals.js");
|
|
const firstUserImportIndex = code.search(
|
|
/import \* as mod_\d+ from "\/tmp\/test\/app\/page\.tsx";/,
|
|
);
|
|
|
|
expect(globalsImportIndex).toBeGreaterThanOrEqual(0);
|
|
expect(firstUserImportIndex).toBeGreaterThanOrEqual(0);
|
|
expect(globalsImportIndex).toBeLessThan(firstUserImportIndex);
|
|
});
|
|
|
|
it("generateRscEntry fails with a path-specific error when a static metadata file cannot be read", () => {
|
|
const metadataRoutes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath: "/tmp/test/app/missing-icon.png",
|
|
routePrefix: "",
|
|
servedUrl: "/icon.png",
|
|
contentType: "image/png",
|
|
},
|
|
];
|
|
|
|
expect(() =>
|
|
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
|
|
).toThrow("[vinext] Failed to read metadata route file /tmp/test/app/missing-icon.png");
|
|
});
|
|
|
|
it("generateRscEntry fails with a path-specific error when a dynamic metadata file hash cannot be read", () => {
|
|
const metadataRoutes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: true,
|
|
filePath: "/tmp/test/app/missing-icon.tsx",
|
|
routePrefix: "",
|
|
servedUrl: "/icon",
|
|
contentType: "image/png",
|
|
},
|
|
];
|
|
|
|
expect(() =>
|
|
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
|
|
).toThrow("[vinext] Failed to read metadata route file /tmp/test/app/missing-icon.tsx");
|
|
});
|
|
|
|
it("generateRscEntry fails with a path-specific error when static image dimensions cannot be read", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-metadata-"));
|
|
const filePath = path.join(tmpDir, "icon.png");
|
|
fs.writeFileSync(filePath, "not a png");
|
|
const metadataRoutes: MetadataFileRoute[] = [
|
|
{
|
|
type: "icon",
|
|
isDynamic: false,
|
|
filePath,
|
|
routePrefix: "",
|
|
servedUrl: "/icon.png",
|
|
contentType: "image/png",
|
|
},
|
|
];
|
|
|
|
try {
|
|
expect(() =>
|
|
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
|
|
).toThrow(`[vinext] Failed to read metadata image dimensions for ${filePath} (/icon.png)`);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("generateRscEntry does not read image dimensions for static text metadata files", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-metadata-"));
|
|
const filePath = path.join(tmpDir, "manifest.json");
|
|
fs.writeFileSync(filePath, JSON.stringify({ name: "test" }));
|
|
const metadataRoutes: MetadataFileRoute[] = [
|
|
{
|
|
type: "manifest",
|
|
isDynamic: false,
|
|
filePath,
|
|
routePrefix: "",
|
|
servedUrl: "/manifest.webmanifest",
|
|
contentType: "application/manifest+json",
|
|
},
|
|
];
|
|
|
|
try {
|
|
expect(() =>
|
|
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
|
|
).not.toThrow();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("generateRscEntry delegates App Router request handling to the typed helper", () => {
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
|
|
expect(code).toContain("app-rsc-handler.js");
|
|
expect(code).toContain("export default __createAppRscHandler({");
|
|
expect(code).not.toContain("computeRscCacheBustingSearchParam(");
|
|
});
|
|
|
|
it("generateRscEntry threads globalNotFoundPath from config into the fallback renderer", () => {
|
|
// The generated entry's createAppFallbackRenderer call must receive a
|
|
// globalNotFoundModule binding so route-miss 404s can render
|
|
// app/global-not-found.tsx standalone.
|
|
// See packages/vinext/src/entries/app-rsc-entry.ts and
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, {
|
|
globalNotFoundPath: "/tmp/test/app/global-not-found.tsx",
|
|
});
|
|
|
|
expect(code).toContain('from "/tmp/test/app/global-not-found.tsx"');
|
|
// The renderer is wired with the module binding (not just `null`).
|
|
expect(code).toContain("globalNotFoundModule,");
|
|
expect(code).not.toContain("const globalNotFoundModule = null;");
|
|
});
|
|
|
|
it("generateRscEntry emits a null globalNotFoundModule when no path is provided", () => {
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
|
|
expect(code).toContain("const globalNotFoundModule = null;");
|
|
expect(code).not.toContain("global-not-found.tsx");
|
|
});
|
|
|
|
it("generateRscEntry delegates React Flight preload hint normalization", () => {
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
|
|
expect(code).toContain("createRscRenderer");
|
|
expect(code).toContain(
|
|
"const renderToReadableStream = createRscRenderer(_renderToReadableStream",
|
|
);
|
|
expect(code).not.toContain("const _hlFixRe =");
|
|
});
|
|
});
|
|
|
|
// ── Pages Router entry template runtime bootstrap ─────────────────────
|
|
|
|
describe("Pages Router entry template", () => {
|
|
it("installs server globals before Pages Router user modules are imported", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-entry-"));
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
|
|
try {
|
|
fs.mkdirSync(pagesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pagesDir, "index.tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
|
|
const code = await generateServerEntry(
|
|
pagesDir,
|
|
await resolveNextConfig({}),
|
|
createValidFileMatcher(),
|
|
null,
|
|
null,
|
|
);
|
|
|
|
const globalsImportIndex = code.indexOf("/server-globals.js");
|
|
const firstUserImportIndex = code.indexOf(
|
|
`import * as page_0 from ${JSON.stringify(path.join(pagesDir, "index.tsx"))}`,
|
|
);
|
|
|
|
expect(globalsImportIndex).toBeGreaterThanOrEqual(0);
|
|
expect(firstUserImportIndex).toBeGreaterThanOrEqual(0);
|
|
expect(globalsImportIndex).toBeLessThan(firstUserImportIndex);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|