Files
cloudflare__vinext/tests/app-layout-param-observation.test.ts
T
Nathan Nguyen 0ecd05dfe1 test(app-router): remove live network from ISR revalidation fixtures (#1756)
* test(app-router): remove live network from ISR revalidation fixtures

The App Router ISR revalidation fixtures fetch httpbin.org only to collect next.tags before rendering request ids. That makes CI depend on external network latency even though the tests never assert the remote response body.

Use a deterministic data URL for the tagged fetch in the parent and nested fixtures so tag collection still exercises the ISR invalidation path without waiting on an external service.

* test: remove incidental external fetches

A layout observation unit test and a dynamic-error fixture used external URLs even though their contracts only need fetch observation or no-store policy detection. Those URLs can start unnecessary network work when the test behavior does not depend on a remote response.

Use deterministic data URLs so the same fetch branches and cache options are exercised without relying on external network availability.
2026-06-05 14:38:01 +01:00

277 lines
9.8 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
createAppLayoutParamAccessTracker,
getStaticLayoutObservationSkipRejection,
isAppLayoutObservationUnsafeForStaticReuse,
type AppLayoutParamAccessObservation,
} from "../packages/vinext/src/server/app-layout-param-observation.js";
import {
cacheLife,
MemoryCacheHandler,
setCacheHandler,
unstable_cache,
} from "../packages/vinext/src/shims/cache.js";
import { ensureFetchPatch } from "../packages/vinext/src/shims/fetch-cache.js";
import {
createRequestContext,
getRequestContext,
runWithRequestContext,
} from "../packages/vinext/src/shims/unified-request-context.js";
import {
markDynamicUsage,
markRenderRequestApiUsage,
} from "../packages/vinext/src/shims/headers.js";
function createObservation(
overrides: Partial<AppLayoutParamAccessObservation> = {},
): AppLayoutParamAccessObservation {
return {
cacheLifeObserved: false,
cacheTags: [],
cacheableFetchCount: 0,
completeness: "complete",
dynamicFetchCount: 0,
dynamicUsageObserved: false,
finiteRevalidateSeconds: null,
keys: [],
observed: false,
paramScopeKeys: [],
requestApis: [],
unstableCaches: [],
...overrides,
};
}
describe("app layout param observation", () => {
it("folds a probe-scoped markDynamicUsage() into the observation as unsafe for static reuse", () => {
// Regression guard: the probe runs inside an isolated child scope that
// resets `dynamicUsageDetected`, so a `markDynamicUsage()` with no other
// observable trace (e.g. `"use cache: private"`) would be discarded when
// the scope exits — masking the Layer-3 `dynamicDetected` signal. The
// tracker must capture it so the two signals can't diverge.
const tracker = createAppLayoutParamAccessTracker();
void runWithRequestContext(createRequestContext(), () => {
tracker.runLayoutProbe("layout:/dynamic", () => {
// No request API, cache tag, fetch, or param access — only the raw
// dynamic-usage flag, which is the path the old code caught via
// `consumeDynamicUsage()`.
markDynamicUsage();
});
tracker.runLayoutProbe("layout:/static", () => null);
});
const dynamicObservation = tracker.getLayoutObservation("layout:/dynamic");
expect(dynamicObservation.dynamicUsageObserved).toBe(true);
expect(isAppLayoutObservationUnsafeForStaticReuse(dynamicObservation)).toBe(true);
const staticObservation = tracker.getLayoutObservation("layout:/static");
expect(staticObservation.dynamicUsageObserved).toBe(false);
expect(isAppLayoutObservationUnsafeForStaticReuse(staticObservation)).toBe(false);
});
it("returns a distinct skip rejection for each unsafe observation shape", () => {
const unsafeCases = [
{
code: "SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE",
name: "incomplete probe",
observation: createObservation({ completeness: "unknown" }),
},
{
code: "SKIP_LAYOUT_PARAMS_PRESENT",
name: "param scope keys",
observation: createObservation({ paramScopeKeys: ["slug"] }),
},
{
code: "SKIP_LAYOUT_PARAMS_OBSERVED",
name: "observed params",
observation: createObservation({ keys: ["slug"], observed: true }),
},
{
code: "SKIP_LAYOUT_DYNAMIC_USAGE_OBSERVED",
name: "dynamic usage",
observation: createObservation({ dynamicUsageObserved: true }),
},
{
code: "SKIP_LAYOUT_REQUEST_API_OBSERVED",
name: "request APIs",
observation: createObservation({ requestApis: ["headers"] }),
},
{
code: "SKIP_LAYOUT_REVALIDATE_PRESENT",
name: "finite revalidate",
observation: createObservation({ finiteRevalidateSeconds: 60 }),
},
{
code: "SKIP_LAYOUT_CACHE_LIFE_OBSERVED",
name: "cacheLife",
observation: createObservation({ cacheLifeObserved: true }),
},
{
code: "SKIP_LAYOUT_UNSTABLE_CACHE_OBSERVED",
name: "unstable_cache",
observation: createObservation({
unstableCaches: [
{
keyHash: "abc123",
kind: "unstable_cache",
revalidate: 60,
tagCount: 1,
tagHash: "tag123",
},
],
}),
},
{
code: "SKIP_LAYOUT_CACHE_TAGS_OBSERVED",
name: "cache tags",
observation: createObservation({ cacheTags: ["tag:dashboard"] }),
},
{
code: "SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED",
name: "cacheable fetches",
observation: createObservation({ cacheableFetchCount: 1 }),
},
{
code: "SKIP_LAYOUT_DYNAMIC_FETCHES_OBSERVED",
name: "dynamic fetches",
observation: createObservation({ dynamicFetchCount: 1 }),
},
] as const;
for (const testCase of unsafeCases) {
const rejection = getStaticLayoutObservationSkipRejection(testCase.observation);
expect(rejection?.code, testCase.name).toBe(testCase.code);
expect(rejection?.fields.dynamicUsageObserved, testCase.name).toBe(
testCase.observation.dynamicUsageObserved,
);
}
const safeObservation = createObservation();
expect(getStaticLayoutObservationSkipRejection(safeObservation)).toBeNull();
expect(isAppLayoutObservationUnsafeForStaticReuse(safeObservation)).toBe(false);
});
it("isolates fetch and cacheLife observations to the current layout probe", async () => {
const tracker = createAppLayoutParamAccessTracker();
await runWithRequestContext(createRequestContext(), async () => {
await tracker.runLayoutProbe("layout:/dashboard/settings", () => {
const ctx = getRequestContext();
ctx.currentRequestTags.push("shared-tag");
ctx.cacheableFetchUrls.add("https://example.com/settings");
ctx.dynamicFetchUrls.add("https://example.com/settings-dynamic");
cacheLife("seconds");
markRenderRequestApiUsage("headers");
});
await tracker.runLayoutProbe("layout:/dashboard", () => null);
await tracker.runLayoutProbe("layout:/dashboard/profile", () => {
const ctx = getRequestContext();
ctx.currentRequestTags.push("shared-tag");
ctx.cacheableFetchUrls.add("https://example.com/profile");
markRenderRequestApiUsage("cookies");
});
});
expect(tracker.getLayoutObservation("layout:/dashboard/settings")).toMatchObject({
cacheLifeObserved: true,
cacheTags: ["shared-tag"],
cacheableFetchCount: 1,
dynamicFetchCount: 1,
requestApis: ["headers"],
});
expect(tracker.getLayoutObservation("layout:/dashboard")).toMatchObject({
cacheLifeObserved: false,
cacheTags: [],
cacheableFetchCount: 0,
dynamicFetchCount: 0,
requestApis: [],
});
expect(tracker.getLayoutObservation("layout:/dashboard/profile")).toMatchObject({
cacheLifeObserved: false,
cacheTags: ["shared-tag"],
cacheableFetchCount: 1,
dynamicFetchCount: 0,
requestApis: ["cookies"],
});
});
it("records unstable_cache dependencies on cache miss and hit", async () => {
setCacheHandler(new MemoryCacheHandler());
const tracker = createAppLayoutParamAccessTracker();
let calls = 0;
const cached = unstable_cache(
async () => {
calls += 1;
return `banner-${calls}`;
},
["layout-banner"],
{ tags: ["banner"], revalidate: 60 },
);
await runWithRequestContext(createRequestContext(), async () => {
await tracker.runLayoutProbe("layout:/miss", () => cached());
await tracker.runLayoutProbe("layout:/hit", () => cached());
});
expect(calls).toBe(1);
expect(tracker.getLayoutObservation("layout:/miss")).toMatchObject({
unstableCaches: [
{
kind: "unstable_cache",
revalidate: 60,
tagCount: 1,
},
],
});
expect(tracker.getLayoutObservation("layout:/hit")).toMatchObject({
unstableCaches: [
{
kind: "unstable_cache",
revalidate: 60,
tagCount: 1,
},
],
});
});
it("observes cacheable fetch dependencies synchronously, before the fetch settles", () => {
ensureFetchPatch();
setCacheHandler(new MemoryCacheHandler());
const tracker = createAppLayoutParamAccessTracker();
// runLayoutProbe runs the probe synchronously, snapshots
// observations, and marks the probe complete — all before the
// fetch promise settles. If the cacheable-fetch observation is
// deferred past await buildFetchCacheKey(), this assertion fails
// because the probe will have already snapshotted.
void runWithRequestContext(createRequestContext(), () => {
// Use runLayoutProbe synchronously (probe returns non-promise)
// so the sync path executes recordProbeDependencies immediately.
tracker.runLayoutProbe("layout:/banner", () => {
// Do not await the fetch — it must still be observable.
// Catch the background promise so it cannot produce an
// unhandled rejection after the synchronous assertions
// complete. The patched fetch continues past observation
// into cache lookup and fetch completion. Use a deterministic data URL
// because the test only needs the synchronous observation side effect.
fetch("data:application/json,%7B%22ok%22%3Atrue%7D", {
next: { revalidate: 60, tags: ["banner"] },
}).catch(() => {});
});
});
// The observation must already include the cacheable fetch
// dependency, because recordCacheableFetchObservation runs
// synchronously before any await in the patched fetch branch.
expect(tracker.getLayoutObservation("layout:/banner")).toMatchObject({
cacheableFetchCount: 1,
cacheTags: ["banner"],
completeness: "complete",
});
});
});