mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b4898289e7
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)
Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
- test/e2e/app-dir/app/index.test.ts L362, L371
- test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80
Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.
* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'
Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
- test/e2e/app-dir/app/index.test.ts (several cases)
- test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
- test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16
Next.js source citations (in .nextjs-ref):
- packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
- packages/next/src/build/templates/app-route.ts L170, L349
- packages/next/src/build/templates/app-page.ts L701, L1043
- packages/next/src/pages/_error.tsx L7
- packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)
Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
computeRscCacheBustingSearchParam,
|
|
createRscRequestHeaders,
|
|
createRscRequestUrl,
|
|
VINEXT_RSC_VARY_HEADER,
|
|
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import { createAppRscHandler } from "../packages/vinext/src/server/app-rsc-handler.js";
|
|
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
|
|
|
|
type TestRoute = {
|
|
isDynamic: boolean;
|
|
page?: { default?: unknown } | null;
|
|
pattern: string;
|
|
rootParamNames?: readonly string[];
|
|
routeHandler?: { GET?: () => Response } | null;
|
|
routeSegments: readonly string[];
|
|
};
|
|
|
|
type HandlerOptions = Parameters<typeof createAppRscHandler<TestRoute>>[0];
|
|
|
|
function createPageRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
|
return {
|
|
isDynamic: false,
|
|
page: { default() {} },
|
|
pattern: "/about",
|
|
routeSegments: ["about"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createHandler(overrides: Partial<HandlerOptions> = {}) {
|
|
const route = createPageRoute();
|
|
|
|
return createAppRscHandler<TestRoute>({
|
|
basePath: "/docs",
|
|
clearRequestContext: overrides.clearRequestContext ?? (() => {}),
|
|
configHeaders: overrides.configHeaders ?? [
|
|
{
|
|
source: "/about",
|
|
headers: [{ key: "x-test-header", value: "applied" }],
|
|
},
|
|
],
|
|
configRedirects: overrides.configRedirects ?? [],
|
|
configRewrites: overrides.configRewrites ?? {
|
|
afterFiles: [],
|
|
beforeFiles: [],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage:
|
|
overrides.dispatchMatchedPage ??
|
|
(async () => new Response("page", { status: 200, headers: { "x-from-dispatch": "page" } })),
|
|
dispatchMatchedRouteHandler:
|
|
overrides.dispatchMatchedRouteHandler ?? (async () => new Response("route", { status: 200 })),
|
|
ensureInstrumentation: overrides.ensureInstrumentation,
|
|
handleProgressiveActionRequest: overrides.handleProgressiveActionRequest ?? (async () => null),
|
|
handleServerActionRequest: overrides.handleServerActionRequest ?? (async () => null),
|
|
i18nConfig: overrides.i18nConfig ?? null,
|
|
isMiddlewareProxy: overrides.isMiddlewareProxy ?? false,
|
|
makeThenableParams,
|
|
matchRoute:
|
|
overrides.matchRoute ??
|
|
((pathname: string) =>
|
|
pathname === "/about"
|
|
? {
|
|
params: {},
|
|
route,
|
|
}
|
|
: null),
|
|
metadataRoutes: overrides.metadataRoutes ?? [],
|
|
middlewareModule: overrides.middlewareModule ?? null,
|
|
publicFiles: overrides.publicFiles ?? new Set<string>(),
|
|
renderNotFound: overrides.renderNotFound ?? (async () => null),
|
|
renderPagesFallback: overrides.renderPagesFallback,
|
|
rootParamNamesByPattern: overrides.rootParamNamesByPattern,
|
|
setNavigationContext: overrides.setNavigationContext ?? (() => {}),
|
|
staticParamsMap: overrides.staticParamsMap ?? {},
|
|
trailingSlash: overrides.trailingSlash ?? false,
|
|
validateDevRequestOrigin: overrides.validateDevRequestOrigin ?? (() => null),
|
|
});
|
|
}
|
|
|
|
describe("createAppRscHandler", () => {
|
|
it("wraps dispatch responses with request-scoped finalization", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({ dispatchMatchedPage });
|
|
|
|
const response = await handler(new Request("https://example.test/docs/about"), null);
|
|
|
|
expect(dispatchMatchedPage).toHaveBeenCalledTimes(1);
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-test-header")).toBe("applied");
|
|
expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
|
|
});
|
|
|
|
it("marks progressive action page renders even when decoded form state is null", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedPage,
|
|
async handleProgressiveActionRequest() {
|
|
return { kind: "form-state", formState: null };
|
|
},
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
method: "POST",
|
|
headers: { "content-type": "multipart/form-data; boundary=vinext" },
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
formState: null,
|
|
isProgressiveActionRender: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns config redirects before route dispatch and skips finalization", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
configRedirects: [{ source: "/old-about", destination: "/about", permanent: true }],
|
|
dispatchMatchedPage,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/old-about"), null);
|
|
|
|
expect(response.status).toBe(308);
|
|
expect(response.headers.get("location")).toBe("/docs/about");
|
|
expect(response.headers.get("x-test-header")).toBeNull();
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets middleware redirect headers override earlier matching config headers", async () => {
|
|
// Next.js route order reference:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
dispatchMatchedPage,
|
|
middlewareModule: {
|
|
default: () =>
|
|
new Response(null, {
|
|
status: 307,
|
|
headers: {
|
|
Location: "/login",
|
|
"x-test-header": "middleware",
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/about"), null);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe("/login");
|
|
expect(response.headers.get("x-test-header")).toBe("middleware");
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("carries config headers on middleware redirects when middleware does not override them", async () => {
|
|
// Next.js route order reference:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/resolve-routes.ts
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
dispatchMatchedPage,
|
|
middlewareModule: {
|
|
default: () =>
|
|
new Response(null, {
|
|
status: 307,
|
|
headers: { Location: "/login" },
|
|
}),
|
|
},
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/about"), null);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe("/login");
|
|
expect(response.headers.get("x-test-header")).toBe("applied");
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not duplicate additive config headers on non-redirect middleware responses", async () => {
|
|
const handler = createHandler({
|
|
configHeaders: [
|
|
{
|
|
source: "/about",
|
|
headers: [{ key: "Vary", value: "X-Config" }],
|
|
},
|
|
],
|
|
middlewareModule: {
|
|
default: () =>
|
|
new Response("blocked", {
|
|
status: 401,
|
|
headers: { Vary: "User-Agent" },
|
|
}),
|
|
},
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/about"), null);
|
|
const varyTokens = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(varyTokens).toContain("User-Agent");
|
|
expect(varyTokens).toContain("X-Config");
|
|
expect(varyTokens.filter((token) => token === "X-Config")).toHaveLength(1);
|
|
});
|
|
|
|
it("canonicalizes config redirect locations for RSC requests", async () => {
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const expectedHash = await computeRscCacheBustingSearchParam(headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRedirects: [{ source: "/old-about", destination: "/about?from=old", permanent: false }],
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/old-about.rsc", { headers }),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(
|
|
`https://example.test/docs/about.rsc?from=old&_rsc=${expectedHash}`,
|
|
);
|
|
});
|
|
|
|
it("redirects invalid RSC cache-busting requests before middleware", async () => {
|
|
const middleware = vi.fn(() => new Response("middleware"));
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const expectedHash = await computeRscCacheBustingSearchParam(headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedPage,
|
|
middlewareModule: { default: middleware },
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about.rsc?tab=latest", { headers }),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(
|
|
`/docs/about.rsc?tab=latest&_rsc=${expectedHash}`,
|
|
);
|
|
expect(middleware).not.toHaveBeenCalled();
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render RSC payloads at HTML URLs marked only by RSC headers", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedPage,
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
headers: createRscRequestHeaders(),
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cleanPathname: "/about",
|
|
isRscRequest: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("strips internal RSC cache-busting params before setting navigation context", async () => {
|
|
const setNavigationContext = vi.fn();
|
|
const headers = createRscRequestHeaders();
|
|
const rscUrl = await createRscRequestUrl("/docs/about?tab=latest", headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(setNavigationContext).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
pathname: "/about",
|
|
params: {},
|
|
}),
|
|
);
|
|
const context = setNavigationContext.mock.lastCall?.[0];
|
|
expect(context?.searchParams.get("tab")).toBe("latest");
|
|
expect(context?.searchParams.has("_rsc")).toBe(false);
|
|
});
|
|
|
|
it("runs beforeFiles rewrites before route matching", async () => {
|
|
const matchRoute = vi.fn((pathname: string) =>
|
|
pathname === "/about"
|
|
? {
|
|
params: {},
|
|
route: createPageRoute(),
|
|
}
|
|
: null,
|
|
);
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("rewritten", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRewrites: {
|
|
beforeFiles: [{ source: "/alias", destination: "/about" }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
matchRoute,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/alias"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("rewritten");
|
|
expect(matchRoute).toHaveBeenLastCalledWith("/about");
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cleanPathname: "/about" }),
|
|
);
|
|
});
|
|
|
|
it("does not let afterFiles rewrites override non-dynamic app routes", async () => {
|
|
const routes = {
|
|
"/about": createPageRoute({ pattern: "/about", routeSegments: ["about"] }),
|
|
"/nav": createPageRoute({ pattern: "/nav", routeSegments: ["nav"] }),
|
|
};
|
|
const dispatchMatchedPage = vi.fn(
|
|
async ({ route }) => new Response(`page:${route.pattern}`, { status: 200 }),
|
|
);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [{ source: "/nav", destination: "/about" }],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
matchRoute: (pathname: string) => {
|
|
if (pathname === "/about") return { params: {}, route: routes["/about"] };
|
|
if (pathname === "/nav") return { params: {}, route: routes["/nav"] };
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/nav"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("page:/nav");
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cleanPathname: "/nav", route: routes["/nav"] }),
|
|
);
|
|
});
|
|
|
|
it("runs afterFiles rewrites before dynamic app route matching", async () => {
|
|
const routes = {
|
|
"/about": createPageRoute({ pattern: "/about", routeSegments: ["about"] }),
|
|
dynamicBlog: createPageRoute({
|
|
isDynamic: true,
|
|
pattern: "/blog/:slug",
|
|
routeSegments: ["blog", "[slug]"],
|
|
}),
|
|
};
|
|
const dispatchMatchedPage = vi.fn(
|
|
async ({ route }) => new Response(`page:${route.pattern}`, { status: 200 }),
|
|
);
|
|
const emptyParams: Record<string, string | string[]> = {};
|
|
const legacyParams: Record<string, string | string[]> = { slug: "legacy" };
|
|
const matchRoute: HandlerOptions["matchRoute"] = (pathname) => {
|
|
if (pathname === "/about") return { params: emptyParams, route: routes["/about"] };
|
|
if (pathname === "/blog/legacy") {
|
|
return { params: legacyParams, route: routes.dynamicBlog };
|
|
}
|
|
return null;
|
|
};
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [{ source: "/blog/legacy", destination: "/about" }],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
matchRoute,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/blog/legacy"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("page:/about");
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cleanPathname: "/about", route: routes["/about"] }),
|
|
);
|
|
});
|
|
|
|
it("serves public files before route matching and clears request context", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
const matchRoute = vi.fn(() => null);
|
|
const handler = createHandler({
|
|
clearRequestContext,
|
|
configHeaders: [],
|
|
matchRoute,
|
|
publicFiles: new Set(["/logo.svg"]),
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/logo.svg"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-vinext-static-file")).toBe("%2Flogo.svg");
|
|
expect(response.headers.get("vary")).toBeNull();
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
expect(matchRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets server actions short-circuit routing while still applying final headers", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handleServerActionRequest = vi.fn(
|
|
async () => new Response("action", { status: 200, headers: { "x-action": "done" } }),
|
|
);
|
|
const handler = createHandler({
|
|
configRewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [{ source: "/about", destination: "/rewritten-action" }],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
handleServerActionRequest,
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
method: "POST",
|
|
headers: { "next-action": "abc123" },
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("action");
|
|
expect(response.headers.get("x-action")).toBe("done");
|
|
expect(response.headers.get("x-test-header")).toBe("applied");
|
|
expect(handleServerActionRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({ actionId: "abc123", cleanPathname: "/about" }),
|
|
);
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts the vinext action header name for server actions", async () => {
|
|
const handleServerActionRequest = vi.fn(async () => new Response("action", { status: 200 }));
|
|
const handler = createHandler({ handleServerActionRequest });
|
|
|
|
await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
method: "POST",
|
|
headers: { "x-rsc-action": "vinext-action" },
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(handleServerActionRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({ actionId: "vinext-action" }),
|
|
);
|
|
});
|
|
|
|
it("dispatches route handlers with matched params", async () => {
|
|
const route = createPageRoute({
|
|
page: null,
|
|
pattern: "/api/:id",
|
|
routeHandler: { GET: () => new Response("route") },
|
|
routeSegments: ["api", "[id]"],
|
|
});
|
|
const dispatchMatchedRouteHandler = vi.fn(async () => new Response("route", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedRouteHandler,
|
|
matchRoute: (pathname: string) =>
|
|
pathname === "/api/123"
|
|
? {
|
|
params: { id: "123" },
|
|
route,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/api/123"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(dispatchMatchedRouteHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cleanPathname: "/api/123",
|
|
params: { id: "123" },
|
|
route,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("appends App Router RSC vary values to route handler responses", async () => {
|
|
const route = createPageRoute({
|
|
page: null,
|
|
pattern: "/api/:id",
|
|
routeHandler: { GET: () => new Response("route") },
|
|
routeSegments: ["api", "[id]"],
|
|
});
|
|
const dispatchMatchedRouteHandler = vi.fn(
|
|
async () => new Response("route", { status: 200, headers: { Vary: "User-Agent" } }),
|
|
);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedRouteHandler,
|
|
matchRoute: (pathname: string) =>
|
|
pathname === "/api/123"
|
|
? {
|
|
params: { id: "123" },
|
|
route,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/api/123"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("vary")).toBe(`User-Agent, ${VINEXT_RSC_VARY_HEADER}`);
|
|
});
|
|
|
|
it("clears request context before returning the plain 404 fallback", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
const handler = createHandler({
|
|
clearRequestContext,
|
|
configHeaders: [],
|
|
matchRoute: () => null,
|
|
renderNotFound: async () => null,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/missing"), null);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.text()).toBe("This page could not be found");
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|