mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e4831109c1
The action re-render path was calling buildPageElement() without await, assigning a Promise to element instead of a resolved React element. This caused redirect()/notFound() thrown inside generateMetadata() during action re-renders to become RSC stream errors instead of propagating to the outer catch handler, and caused getAndClearPendingCookies() to run before page tree construction resolved, missing cookies set during page building. Adds a focused test asserting the generated code contains the await so this regression can't silently reappear. Updates snapshots accordingly.
418 lines
16 KiB
TypeScript
418 lines
16 KiB
TypeScript
/**
|
|
* Snapshot tests for the entry template code generators.
|
|
*
|
|
* These tests lock down the exact generated code for all virtual entry modules
|
|
* so that future refactoring (extracting generators into separate files, etc.)
|
|
* can be verified against a known baseline.
|
|
*
|
|
* - App Router generators are standalone exported functions → imported directly.
|
|
* - Pages Router generators are closures inside the plugin → tested via
|
|
* Vite's pluginContainer.load() on the virtual module IDs.
|
|
*/
|
|
import path from "node:path";
|
|
import { describe, it, expect, afterAll } from "vite-plus/test";
|
|
import { createServer, type ViteDevServer } from "vite-plus";
|
|
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
|
|
import type { AppRouterConfig } from "../packages/vinext/src/entries/app-rsc-entry.js";
|
|
import { generateSsrEntry } from "../packages/vinext/src/entries/app-ssr-entry.js";
|
|
import { generateBrowserEntry } from "../packages/vinext/src/entries/app-browser-entry.js";
|
|
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
|
|
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
|
|
// Workspace root (forward-slash normalised) used to replace absolute paths
|
|
// in generated code so snapshots are machine-independent.
|
|
const ROOT = path.resolve(import.meta.dirname, "..").replace(/\\/g, "/");
|
|
|
|
/** Replace all occurrences of the workspace root with `<ROOT>`. */
|
|
function stabilize(code: string): string {
|
|
return code.replaceAll(ROOT, "<ROOT>");
|
|
}
|
|
|
|
// ── Minimal App Router route fixtures ─────────────────────────────────
|
|
// Use stable absolute paths so snapshots 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],
|
|
forbiddenPath: null,
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
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],
|
|
forbiddenPath: null,
|
|
unauthorizedPath: null,
|
|
routeSegments: ["about"],
|
|
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],
|
|
forbiddenPath: null,
|
|
unauthorizedPath: null,
|
|
routeSegments: ["blog", ":slug"],
|
|
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"],
|
|
forbiddenPath: null,
|
|
unauthorizedPath: null,
|
|
routeSegments: ["dashboard"],
|
|
layoutTreePositions: [0, 1],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
];
|
|
|
|
// ── Pages Router fixture ──────────────────────────────────────────────
|
|
// NOTE: Adding, removing, or renaming pages in this fixture will break the
|
|
// Pages Router snapshots below. Run `pnpm test tests/entry-templates.test.ts -u`
|
|
// to update them after intentional fixture changes.
|
|
const PAGES_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
|
|
|
|
// ── App Router entry templates ────────────────────────────────────────
|
|
|
|
describe("App Router entry templates", () => {
|
|
it("generateRscEntry snapshot (minimal routes)", () => {
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
null, // no middleware
|
|
[], // no metadata routes
|
|
null, // no global error
|
|
"", // no basePath
|
|
false, // no trailingSlash
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry snapshot (with middleware)", () => {
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
"/tmp/test/middleware.ts",
|
|
[],
|
|
null,
|
|
"",
|
|
false,
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry snapshot (with instrumentation)", () => {
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
null,
|
|
[],
|
|
null,
|
|
"",
|
|
false,
|
|
undefined,
|
|
"/tmp/test/instrumentation.ts",
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry snapshot (with global error)", () => {
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
null,
|
|
[],
|
|
"/tmp/test/app/global-error.tsx",
|
|
"",
|
|
false,
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry snapshot (with config)", () => {
|
|
const config: AppRouterConfig = {
|
|
redirects: [{ source: "/old", destination: "/new", permanent: true }],
|
|
rewrites: {
|
|
beforeFiles: [{ source: "/api/:path*", destination: "/backend/:path*" }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
},
|
|
headers: [
|
|
{
|
|
source: "/api/:path*",
|
|
headers: [{ key: "X-Custom", value: "test" }],
|
|
},
|
|
],
|
|
allowedOrigins: ["https://example.com"],
|
|
allowedDevOrigins: ["localhost:3001"],
|
|
};
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
null,
|
|
[],
|
|
null,
|
|
"/base",
|
|
true,
|
|
config,
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry snapshot (with metadata routes)", () => {
|
|
const metadataRoutes: MetadataFileRoute[] = [
|
|
{
|
|
type: "sitemap",
|
|
isDynamic: true,
|
|
filePath: "/tmp/test/app/sitemap.ts",
|
|
servedUrl: "/sitemap.xml",
|
|
contentType: "application/xml",
|
|
},
|
|
];
|
|
const code = generateRscEntry(
|
|
"/tmp/test/app",
|
|
minimalAppRoutes,
|
|
null,
|
|
metadataRoutes,
|
|
null,
|
|
"",
|
|
false,
|
|
);
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateRscEntry awaits buildPageElement in the server-action re-render path", () => {
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
// The action re-render path must await buildPageElement so that:
|
|
// 1. redirect()/notFound() thrown inside generateMetadata() becomes an HTTP
|
|
// redirect instead of an RSC stream error.
|
|
// 2. getAndClearPendingCookies() runs after the page tree resolves, not
|
|
// before (which would miss cookies set during page building).
|
|
const actionRerenderIdx = code.indexOf(
|
|
"// After the action, re-render the current page so the client",
|
|
);
|
|
expect(actionRerenderIdx).toBeGreaterThan(-1);
|
|
const rerenderSlice = code.slice(actionRerenderIdx, actionRerenderIdx + 700);
|
|
expect(rerenderSlice).toContain(
|
|
"element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams);",
|
|
);
|
|
});
|
|
|
|
it("generateSsrEntry snapshot", () => {
|
|
const code = generateSsrEntry();
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("generateBrowserEntry snapshot", () => {
|
|
const code = generateBrowserEntry();
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
});
|
|
|
|
// ── Pages Router entry templates ──────────────────────────────────────
|
|
// These are closure functions inside the vinext() plugin, so we test
|
|
// them via Vite's pluginContainer.load() on the virtual module IDs.
|
|
|
|
describe("Pages Router entry templates", () => {
|
|
let server: ViteDevServer;
|
|
|
|
afterAll(async () => {
|
|
if (server) await server.close();
|
|
});
|
|
|
|
async function getVirtualModuleCode(moduleId: string): Promise<string> {
|
|
if (!server) {
|
|
server = await createServer({
|
|
root: PAGES_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext()],
|
|
server: { port: 0 },
|
|
logLevel: "silent",
|
|
});
|
|
}
|
|
const resolved = await server.pluginContainer.resolveId(moduleId);
|
|
expect(resolved).toBeTruthy();
|
|
const loaded = await server.pluginContainer.load(resolved!.id);
|
|
expect(loaded).toBeTruthy();
|
|
return typeof loaded === "string"
|
|
? loaded
|
|
: typeof loaded === "object" && loaded !== null && "code" in loaded
|
|
? loaded.code
|
|
: "";
|
|
}
|
|
|
|
it("server entry snapshot", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
|
|
it("server entry uses trie-based route matching", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
expect(stabilize(code)).toContain("buildRouteTrie");
|
|
expect(stabilize(code)).toContain("trieMatch");
|
|
});
|
|
|
|
it("server entry delegates Pages ISR cache plumbing to shared helpers", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
const stableCode = stabilize(code);
|
|
|
|
expect(stableCode).toContain('from "<ROOT>/packages/vinext/src/server/isr-cache.js";');
|
|
expect(code).toContain("function isrGet(key) {");
|
|
expect(code).toContain("return __sharedIsrGet(key);");
|
|
expect(code).toContain("return __sharedTriggerBackgroundRegeneration(key, renderFn);");
|
|
expect(code).not.toContain("const promise = renderFn()");
|
|
expect(code).not.toContain("ctx.waitUntil(promise)");
|
|
});
|
|
|
|
it("server entry seeds the main Pages Router unified context with executionContext", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
const renderPageIndex = code.indexOf("async function _renderPage(request, url, manifest) {");
|
|
const unifiedCtxIndex = code.indexOf("const __uCtx = _createUnifiedCtx({", renderPageIndex);
|
|
|
|
expect(renderPageIndex).toBeGreaterThan(-1);
|
|
expect(unifiedCtxIndex).toBeGreaterThan(renderPageIndex);
|
|
|
|
const renderPageSection = code.slice(unifiedCtxIndex, unifiedCtxIndex + 200);
|
|
expect(renderPageSection).toContain("executionContext: _getRequestExecutionContext(),");
|
|
});
|
|
|
|
it("server entry passes a fresh unified-context ISR runner into the typed page-data helper", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
const runnerIndex = code.indexOf("runInFreshUnifiedContext(callback) {");
|
|
|
|
expect(runnerIndex).toBeGreaterThan(-1);
|
|
|
|
const runnerSection = code.slice(runnerIndex, runnerIndex + 500);
|
|
expect(runnerSection).toContain("_createUnifiedCtx");
|
|
expect(runnerSection).toContain("executionContext: _getRequestExecutionContext()");
|
|
expect(runnerSection).toContain("_runWithUnifiedCtx");
|
|
expect(runnerSection).toContain("ensureFetchPatch();");
|
|
expect(runnerSection).toContain("return callback();");
|
|
});
|
|
|
|
it("server entry delegates Pages HTML stream/response shaping to a typed helper", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
|
|
expect(code).toContain("renderPagesPageResponse as __renderPagesPageResponse");
|
|
expect(code).toContain("return __renderPagesPageResponse({");
|
|
expect(code).not.toContain('var BODY_MARKER = "<!--VINEXT_STREAM_BODY-->";');
|
|
expect(code).not.toContain("var compositeStream = new ReadableStream({");
|
|
});
|
|
|
|
it("server entry delegates Pages data/ISR handling to a typed helper", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
|
|
expect(code).toContain("resolvePagesPageData as __resolvePagesPageData");
|
|
expect(code).toContain("isrGet as __sharedIsrGet");
|
|
expect(code).toContain("isrSet as __sharedIsrSet");
|
|
expect(code).toContain("isrCacheKey as __sharedIsrCacheKey");
|
|
expect(code).toContain(
|
|
"triggerBackgroundRegeneration as __sharedTriggerBackgroundRegeneration",
|
|
);
|
|
expect(code).toContain("const pageDataResult = await __resolvePagesPageData({");
|
|
expect(code).toContain("return __sharedTriggerBackgroundRegeneration(key, renderFn);");
|
|
expect(code).not.toContain("async function isrGet(key)");
|
|
expect(code).not.toContain("async function isrSet(key, data, revalidateSeconds, tags)");
|
|
expect(code).not.toContain("const pendingRegenerations = new Map();");
|
|
expect(code).not.toContain("function fnv1a64(input)");
|
|
expect(code).not.toContain("const result = await pageModule.getServerSideProps(ctx);");
|
|
expect(code).not.toContain("const result = await pageModule.getStaticProps(ctx);");
|
|
});
|
|
|
|
it("server entry delegates Pages API route handling and req/res shims to typed helpers", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
|
|
expect(code).toContain("createPagesReqRes as __createPagesReqRes");
|
|
expect(code).toContain("handlePagesApiRoute as __handlePagesApiRoute");
|
|
expect(code).toContain("return __handlePagesApiRoute({");
|
|
expect(code).not.toContain("function createReqRes(request, url, query, body)");
|
|
expect(code).not.toContain("async function readBodyWithLimit(request, maxBytes)");
|
|
expect(code).not.toContain(
|
|
"const { req, res, responsePromise } = createReqRes(request, url, query, body);",
|
|
);
|
|
});
|
|
|
|
it("server entry isolates the ISR cache-fill rerender in fresh render sub-scopes", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
|
|
expect(code).toContain("async function renderIsrPassToStringAsync(element)");
|
|
expect(code).toContain("runWithServerInsertedHTMLState(() =>");
|
|
expect(code).toContain("runWithHeadState(() =>");
|
|
expect(code).toContain("_runWithCacheState(() =>");
|
|
expect(code).toContain(
|
|
"runWithPrivateCache(() => runWithFetchCache(async () => renderToStringAsync(element)))",
|
|
);
|
|
expect(code).toContain("renderIsrPassToStringAsync,");
|
|
});
|
|
|
|
it("server entry registers i18n state without wrapping the unified request scope", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
|
|
expect(code).toContain('import "vinext/i18n-state";');
|
|
expect(code).not.toContain("return runWithI18nState(() =>");
|
|
});
|
|
|
|
it("server entry calls reportRequestError for SSR and API errors", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-server-entry");
|
|
// The generated prod entry must import reportRequestError
|
|
expect(code).toContain("reportRequestError");
|
|
// SSR page render catch block should report with routeType "render"
|
|
expect(code).toContain('"render"');
|
|
// API route catch block should report with routeType "route"
|
|
expect(code).toContain('"route"');
|
|
});
|
|
|
|
it("client entry snapshot", async () => {
|
|
const code = await getVirtualModuleCode("virtual:vinext-client-entry");
|
|
expect(stabilize(code)).toMatchSnapshot();
|
|
});
|
|
});
|