mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
ebbf72cc8d
* feat(router): expose route graph manifest read model The App Router graph now exposes root-boundary identity and a stable RouteManifest read model instead of leaving these facts implicit inside per-route arrays. Future #726 planner and compatibility work needs a semantic lookup surface for routes, layouts, pages, templates, slots, and root boundaries before it can stop reading meaning from transport keys. This adds RootBoundaryId, graphVersion, StaticSegmentGraph maps, and fail-fast invariant checks for mismatched layout/template positions while preserving the existing routes array for current callers. * chore: retrigger ci
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
buildBoundaryOutcomeCompatibility,
|
|
buildCacheVariant,
|
|
buildRenderObservation,
|
|
createAppRouteCacheProofGraphScope,
|
|
createDisabledCacheProofDecision,
|
|
DEFAULT_CACHE_VARIANT_BUDGET,
|
|
hasCompleteNegativeRequestApiProof,
|
|
type AppRouteCacheProofGraphScopeInput,
|
|
type CacheProofBreakerFallback,
|
|
} from "../packages/vinext/src/server/cache-proof.js";
|
|
|
|
function expectBreakerReason(
|
|
result: ReturnType<typeof buildCacheVariant>,
|
|
code: CacheProofBreakerFallback["code"],
|
|
): CacheProofBreakerFallback {
|
|
expect(result.kind).toBe("breakerFallback");
|
|
if (result.kind !== "breakerFallback") {
|
|
throw new Error("Expected cache variant construction to return a breaker fallback");
|
|
}
|
|
expect(result.fallback.code).toBe(code);
|
|
return result.fallback;
|
|
}
|
|
|
|
describe("disabled cache proof model", () => {
|
|
it("normalizes route graph semantic ids for cache proof scopes", () => {
|
|
const route = {
|
|
ids: {
|
|
route: "route:/shop/:id",
|
|
page: "page:/shop/:id",
|
|
routeHandler: null,
|
|
rootBoundary: "root-boundary:/",
|
|
layouts: ["layout:/", "layout:/shop/[id]"],
|
|
templates: [],
|
|
slots: {
|
|
"zebra@shop/[id]/@zebra": "slot:zebra:/shop/[id]",
|
|
"modal@shop/[id]/@modal": "slot:modal:/shop/[id]",
|
|
"modal-copy@shop/[id]/@modal": "slot:modal:/shop/[id]",
|
|
},
|
|
},
|
|
} satisfies AppRouteCacheProofGraphScopeInput;
|
|
|
|
const scope = createAppRouteCacheProofGraphScope(route);
|
|
|
|
expect(scope).toEqual({
|
|
routeId: "route:/shop/:id",
|
|
pageId: "page:/shop/:id",
|
|
routeHandlerId: null,
|
|
layoutIds: ["layout:/", "layout:/shop/[id]"],
|
|
templateIds: [],
|
|
slotIds: ["slot:modal:/shop/[id]", "slot:zebra:/shop/[id]"],
|
|
});
|
|
});
|
|
|
|
it("canonicalizes dimensions while keeping cache keys and traces redacted", () => {
|
|
const first = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [
|
|
{
|
|
name: "sort",
|
|
privacy: "public",
|
|
source: "search",
|
|
values: ["desc"],
|
|
},
|
|
{
|
|
name: "SORT",
|
|
privacy: "public",
|
|
source: "search",
|
|
values: ["asc", "asc"],
|
|
},
|
|
{
|
|
name: "id",
|
|
privacy: "public",
|
|
source: "params",
|
|
values: ["super-secret-token"],
|
|
},
|
|
],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "app-rsc",
|
|
mountedSlotsFingerprint: "slots:main",
|
|
renderEpoch: null,
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/shop/:id",
|
|
},
|
|
});
|
|
const second = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [
|
|
{
|
|
name: "id",
|
|
privacy: "public",
|
|
source: "params",
|
|
values: ["super-secret-token"],
|
|
},
|
|
{
|
|
name: "sort",
|
|
privacy: "public",
|
|
source: "search",
|
|
values: ["asc", "desc"],
|
|
},
|
|
],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "app-rsc",
|
|
mountedSlotsFingerprint: "slots:main",
|
|
renderEpoch: null,
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/shop/:id",
|
|
},
|
|
});
|
|
|
|
expect(first.kind).toBe("variant");
|
|
expect(second.kind).toBe("variant");
|
|
if (first.kind !== "variant" || second.kind !== "variant") {
|
|
throw new Error("Expected both inputs to produce cache variants");
|
|
}
|
|
|
|
expect(first.variant.cacheKey).toBe(second.variant.cacheKey);
|
|
expect(first.variant.dimensions.map((dimension) => dimension.name)).toEqual(["id", "sort"]);
|
|
expect(first.variant.dimensions[0].valueHashes).toHaveLength(1);
|
|
expect(first.variant.dimensions[1].valueHashes).toHaveLength(2);
|
|
|
|
const serializedVariant = JSON.stringify(first.variant);
|
|
expect(serializedVariant).not.toContain("super-secret-token");
|
|
expect(serializedVariant).not.toContain("desc");
|
|
expect(serializedVariant).not.toContain("asc");
|
|
});
|
|
|
|
it("keeps null and empty-string output scope dimensions distinct", () => {
|
|
const absentEpoch = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "app-html",
|
|
renderEpoch: null,
|
|
rootBoundaryId: null,
|
|
routeId: "route:/",
|
|
},
|
|
});
|
|
const emptyEpoch = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "app-html",
|
|
renderEpoch: "",
|
|
rootBoundaryId: "",
|
|
routeId: "route:/",
|
|
},
|
|
});
|
|
|
|
expect(absentEpoch.kind).toBe("variant");
|
|
expect(emptyEpoch.kind).toBe("variant");
|
|
if (absentEpoch.kind !== "variant" || emptyEpoch.kind !== "variant") {
|
|
throw new Error("Expected both output scopes to produce cache variants");
|
|
}
|
|
|
|
expect(absentEpoch.variant.cacheKey).not.toBe(emptyEpoch.variant.cacheKey);
|
|
});
|
|
|
|
it("returns breaker fallbacks for unsafe or over-budget variants", () => {
|
|
const unsafePublicCookie = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [
|
|
{
|
|
name: "session",
|
|
privacy: "public",
|
|
source: "cookie",
|
|
values: ["abc"],
|
|
},
|
|
],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "layout",
|
|
layoutId: "layout:/account",
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/account",
|
|
},
|
|
});
|
|
|
|
const fallback = expectBreakerReason(unsafePublicCookie, "CP_UNSAFE_PUBLIC_DIMENSION");
|
|
expect(JSON.stringify(fallback.fields)).not.toContain("abc");
|
|
|
|
expectBreakerReason(
|
|
buildCacheVariant({
|
|
budget: {
|
|
...DEFAULT_CACHE_VARIANT_BUDGET,
|
|
maxDimensionValueLength: 4,
|
|
},
|
|
dimensions: [
|
|
{
|
|
name: "tenant",
|
|
privacy: "public",
|
|
source: "params",
|
|
values: ["customer-a"],
|
|
},
|
|
],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "layout",
|
|
layoutId: "layout:/[tenant]",
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/:tenant",
|
|
},
|
|
}),
|
|
"CP_DIMENSION_VALUE_TOO_LONG",
|
|
);
|
|
|
|
expectBreakerReason(
|
|
buildCacheVariant({
|
|
budget: {
|
|
...DEFAULT_CACHE_VARIANT_BUDGET,
|
|
maxVariantsPerRoute: 2,
|
|
},
|
|
dimensions: [
|
|
{
|
|
name: "tenant",
|
|
privacy: "public",
|
|
source: "params",
|
|
values: ["a"],
|
|
},
|
|
],
|
|
existingVariantCount: 2,
|
|
output: {
|
|
kind: "layout",
|
|
layoutId: "layout:/[tenant]",
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/:tenant",
|
|
},
|
|
}),
|
|
"CP_ROUTE_VARIANT_CEILING_EXCEEDED",
|
|
);
|
|
|
|
const invalidBudget = buildCacheVariant({
|
|
budget: {
|
|
...DEFAULT_CACHE_VARIANT_BUDGET,
|
|
maxEncodedLength: -1,
|
|
},
|
|
dimensions: [],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "layout",
|
|
layoutId: "layout:/[tenant]",
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/:tenant",
|
|
},
|
|
});
|
|
expect(invalidBudget.kind).toBe("breakerFallback");
|
|
if (invalidBudget.kind !== "breakerFallback") {
|
|
throw new Error("Expected invalid cache variant budget to return a breaker fallback");
|
|
}
|
|
expect(invalidBudget.fallback).toMatchObject({
|
|
code: "CP_INVALID_VARIANT_BUDGET",
|
|
fields: { budgetField: "maxEncodedLength" },
|
|
});
|
|
});
|
|
|
|
it("requires complete negative request-api observations before absence is proof", () => {
|
|
const complete = buildRenderObservation({
|
|
boundaryOutcome: { kind: "success" },
|
|
cacheability: "public",
|
|
cacheTags: ["posts", "posts"],
|
|
completeness: "complete",
|
|
dynamicFetches: ["https://api.example.test/posts?token=secret"],
|
|
output: {
|
|
kind: "layout",
|
|
layoutId: "layout:/blog",
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/blog",
|
|
},
|
|
pathTags: ["/blog"],
|
|
requestApis: [
|
|
{ kind: "headers", status: "notObserved" },
|
|
{ kind: "cookies", status: "notObserved" },
|
|
],
|
|
});
|
|
const partial = buildRenderObservation({
|
|
...complete,
|
|
completeness: "partial",
|
|
});
|
|
const observed = buildRenderObservation({
|
|
...complete,
|
|
requestApis: [
|
|
{ kind: "headers", status: "observed" },
|
|
{ kind: "cookies", status: "notObserved" },
|
|
],
|
|
});
|
|
const missingApiKind = buildRenderObservation({
|
|
...complete,
|
|
requestApis: [{ kind: "cookies", status: "notObserved" }],
|
|
});
|
|
|
|
expect(hasCompleteNegativeRequestApiProof(complete, ["headers", "cookies"])).toBe(true);
|
|
expect(hasCompleteNegativeRequestApiProof(partial, ["headers", "cookies"])).toBe(false);
|
|
expect(hasCompleteNegativeRequestApiProof(observed, ["headers", "cookies"])).toBe(false);
|
|
expect(hasCompleteNegativeRequestApiProof(missingApiKind, ["headers", "cookies"])).toBe(false);
|
|
expect(complete.cacheTags).toEqual(["posts"]);
|
|
expect(JSON.stringify(complete.dynamicFetches)).not.toContain("secret");
|
|
});
|
|
|
|
it("treats boundary outcome compatibility as exact-match only", () => {
|
|
expect(
|
|
buildBoundaryOutcomeCompatibility({
|
|
candidate: { kind: "success" },
|
|
expected: { kind: "success" },
|
|
}).kind,
|
|
).toBe("compatible");
|
|
|
|
const mismatch = buildBoundaryOutcomeCompatibility({
|
|
candidate: { kind: "notFound" },
|
|
expected: { kind: "success" },
|
|
});
|
|
expect(mismatch.kind).toBe("incompatible");
|
|
if (mismatch.kind === "incompatible") {
|
|
expect(mismatch.fallback.code).toBe("CP_BOUNDARY_OUTCOME_MISMATCH");
|
|
}
|
|
|
|
const unknown = buildBoundaryOutcomeCompatibility({
|
|
candidate: { kind: "unknown" },
|
|
expected: { kind: "success" },
|
|
});
|
|
expect(unknown.kind).toBe("incompatible");
|
|
if (unknown.kind === "incompatible") {
|
|
expect(unknown.fallback.code).toBe("CP_BOUNDARY_OUTCOME_UNKNOWN");
|
|
}
|
|
});
|
|
|
|
it("never authorizes runtime reuse while the proof model is disabled", () => {
|
|
const variant = buildCacheVariant({
|
|
budget: DEFAULT_CACHE_VARIANT_BUDGET,
|
|
dimensions: [],
|
|
existingVariantCount: 0,
|
|
output: {
|
|
kind: "app-html",
|
|
renderEpoch: null,
|
|
rootBoundaryId: "layout:/",
|
|
routeId: "route:/",
|
|
},
|
|
});
|
|
|
|
expect(variant.kind).toBe("variant");
|
|
if (variant.kind !== "variant") {
|
|
throw new Error("Expected cache variant construction to succeed");
|
|
}
|
|
|
|
const decision = createDisabledCacheProofDecision({
|
|
variant: variant.variant,
|
|
observation: buildRenderObservation({
|
|
boundaryOutcome: { kind: "success" },
|
|
cacheability: "public",
|
|
cacheTags: [],
|
|
completeness: "complete",
|
|
dynamicFetches: [],
|
|
output: variant.variant.output,
|
|
pathTags: [],
|
|
requestApis: [],
|
|
}),
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
canReuse: false,
|
|
kind: "disabled",
|
|
fallback: {
|
|
code: "CP_MODEL_DISABLED",
|
|
mode: "renderFresh",
|
|
scope: "affectedOutput",
|
|
},
|
|
});
|
|
});
|
|
});
|