From 1b4c22026d54a12d1662d55e791548a3f893fff8 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 23:14:35 +0200 Subject: [PATCH] test: add integration test cases for use-cache --- .../app/api/revalidate/[...slug]/route.ts | 12 + .../dynamic/ttl-1year/[slug]/page.tsx | 32 ++ .../dynamic/ttl-5seconds/[slug]/page.tsx | 32 ++ .../static/ttl-10seconds/[slug]/page.tsx | 38 ++ .../static/ttl-1year/[slug]/page.tsx | 37 ++ .../dynamic/ttl-1year/[slug]/page.tsx | 33 ++ .../dynamic/ttl-5seconds/[slug]/page.tsx | 33 ++ .../static/ttl-10seconds/[slug]/page.tsx | 39 ++ .../static/ttl-1year/[slug]/page.tsx | 38 ++ .../dynamic/ttl-1year/[slug]/page.tsx | 34 ++ .../dynamic/ttl-5seconds/[slug]/page.tsx | 34 ++ .../static/ttl-10seconds/[slug]/page.tsx | 39 ++ .../static/ttl-1year/[slug]/page.tsx | 38 ++ tests/fixtures/use-cache/app/helpers.tsx | 73 +++ tests/fixtures/use-cache/app/layout.js | 12 + tests/fixtures/use-cache/next-env.d.ts | 5 + tests/fixtures/use-cache/next.config.js | 35 ++ tests/fixtures/use-cache/package.json | 23 + tests/fixtures/use-cache/tsconfig.json | 24 + tests/integration/use-cache.test.ts | 429 ++++++++++++++++++ 20 files changed, 1040 insertions(+) create mode 100644 tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts create mode 100644 tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-5seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-10seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-5seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-10seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-5seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-10seconds/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-1year/[slug]/page.tsx create mode 100644 tests/fixtures/use-cache/app/helpers.tsx create mode 100644 tests/fixtures/use-cache/app/layout.js create mode 100644 tests/fixtures/use-cache/next-env.d.ts create mode 100644 tests/fixtures/use-cache/next.config.js create mode 100644 tests/fixtures/use-cache/package.json create mode 100644 tests/fixtures/use-cache/tsconfig.json create mode 100644 tests/integration/use-cache.test.ts diff --git a/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts b/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts new file mode 100644 index 0000000000..4900705b4b --- /dev/null +++ b/tests/fixtures/use-cache/app/api/revalidate/[...slug]/route.ts @@ -0,0 +1,12 @@ +import { revalidateTag } from 'next/cache' +import { NextRequest } from 'next/server' + +export async function GET(request: NextRequest, { params }) { + const { slug } = await params + + const tagToInvalidate = slug.join('/') + + revalidateTag(tagToInvalidate) + + return Response.json({ tagToInvalidate }) +} diff --git a/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..b6cb766e3e --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-1year/[slug]/page.tsx @@ -0,0 +1,32 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + 'use cache' + cacheTag(`component/${props.route}`) + cacheLife('1year') + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-5seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-5seconds/[slug]/page.tsx new file mode 100644 index 0000000000..2376bcefa5 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-component/dynamic/ttl-5seconds/[slug]/page.tsx @@ -0,0 +1,32 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + 'use cache' + cacheTag(`component/${props.route}`) + cacheLife('5seconds') + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-10seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-10seconds/[slug]/page.tsx new file mode 100644 index 0000000000..4e79023f1d --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-10seconds/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + 'use cache' + cacheTag(`component/${props.route}`) + cacheLife('10seconds') // longer TTL than page revalidate to test interaction + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const revalidate = 5 +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..63fe621296 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-component/static/ttl-1year/[slug]/page.tsx @@ -0,0 +1,37 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + 'use cache' + cacheTag(`component/${props.route}`) + cacheLife('1year') + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..f07a3e05e4 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-1year/[slug]/page.tsx @@ -0,0 +1,33 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + 'use cache' + cacheTag(`data/${route}`) + cacheLife('1year') + + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-5seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-5seconds/[slug]/page.tsx new file mode 100644 index 0000000000..25517481fd --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-data/dynamic/ttl-5seconds/[slug]/page.tsx @@ -0,0 +1,33 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + 'use cache' + cacheTag(`data/${route}`) + cacheLife('5seconds') + + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-10seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-10seconds/[slug]/page.tsx new file mode 100644 index 0000000000..32374f0910 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-10seconds/[slug]/page.tsx @@ -0,0 +1,39 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + 'use cache' + cacheTag(`data/${route}`) + cacheLife('10seconds') // longer TTL than page revalidate to test interaction + + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const revalidate = 5 +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..4da6621b65 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-data/static/ttl-1year/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + 'use cache' + cacheTag(`data/${route}`) + cacheLife('1year') + + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..416e2d9ac5 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-1year/[slug]/page.tsx @@ -0,0 +1,34 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + 'use cache' + const routeRoot = 'default/use-cache-page/dynamic/ttl-1year' + cacheTag(`page/${routeRoot}/${(await params).slug}`) + cacheLife('1year') + + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-5seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-5seconds/[slug]/page.tsx new file mode 100644 index 0000000000..a4939f2597 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-page/dynamic/ttl-5seconds/[slug]/page.tsx @@ -0,0 +1,34 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + 'use cache' + const routeRoot = 'default/use-cache-page/dynamic/ttl-5seconds' + cacheTag(`page/${routeRoot}/${(await params).slug}`) + cacheLife('5seconds') + + return ( + + ) +} + +export const dynamic = 'force-dynamic' diff --git a/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-10seconds/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-10seconds/[slug]/page.tsx new file mode 100644 index 0000000000..bc50627f89 --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-10seconds/[slug]/page.tsx @@ -0,0 +1,39 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + 'use cache' + const routeRoot = 'default/use-cache-page/static/ttl-10seconds' + cacheTag(`page/${routeRoot}/${(await params).slug}`) + cacheLife('10seconds') // longer TTL than page revalidate to test interaction + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const revalidate = 5 +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-1year/[slug]/page.tsx b/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-1year/[slug]/page.tsx new file mode 100644 index 0000000000..ed6c3e68dc --- /dev/null +++ b/tests/fixtures/use-cache/app/default/use-cache-page/static/ttl-1year/[slug]/page.tsx @@ -0,0 +1,38 @@ +import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache' +import { + BasePageComponentProps, + generateStaticParamsImplementation, + getDataImplementation, + PageComponentImplementation, + ResultComponentImplementation, + ResultWrapperComponentProps, +} from '../../../../../helpers' + +async function getData(route: string) { + return await getDataImplementation(route) +} + +async function ResultWrapperComponent(props: ResultWrapperComponentProps) { + return +} + +export default async function PageComponent({ params }: BasePageComponentProps) { + 'use cache' + const routeRoot = 'default/use-cache-page/static/ttl-1year' + cacheTag(`page/${routeRoot}/${(await params).slug}`) + cacheLife('1year') + return ( + + ) +} + +export function generateStaticParams() { + return generateStaticParamsImplementation() +} + +export const dynamic = 'force-static' diff --git a/tests/fixtures/use-cache/app/helpers.tsx b/tests/fixtures/use-cache/app/helpers.tsx new file mode 100644 index 0000000000..1ef24d15ab --- /dev/null +++ b/tests/fixtures/use-cache/app/helpers.tsx @@ -0,0 +1,73 @@ +export type Data = { + data: string + time: string +} + +export async function getDataImplementation(route: string): Promise { + const res = await fetch(`https://strangerthings-quotes.vercel.app/api/quotes`) + return { + data: (await res.json())[0].quote, + time: new Date().toISOString(), + } +} + +export type ResultWrapperComponentProps = { + route: string + children: React.ReactNode +} + +export async function ResultComponentImplementation({ + route, + children, +}: ResultWrapperComponentProps) { + return ( + <> + {children} +
Time (GetDataResult)
+
{new Date().toISOString()}
+ + ) +} + +export type BasePageComponentProps = { + params: Promise<{ slug: string }> +} + +export async function PageComponentImplementation({ + getData, + ResultWrapperComponent, + params, + routeRoot, +}: BasePageComponentProps & { + routeRoot: string + getData: typeof getDataImplementation + ResultWrapperComponent: typeof ResultComponentImplementation +}) { + const { slug } = await params + const route = `${routeRoot}/${slug}` + const { data, time } = await getData(route) + + return ( + <> +

Hello, use-cache - {route}

+
+ +
Quote (getData)
+
{data}
+
Time (getData)
+
{time}
+
+
Time (PageComponent)
+
{new Date().toISOString()}
+
+ + ) +} + +export function generateStaticParamsImplementation() { + return [ + { + slug: 'prerendered', + }, + ] +} diff --git a/tests/fixtures/use-cache/app/layout.js b/tests/fixtures/use-cache/app/layout.js new file mode 100644 index 0000000000..1b1466fa60 --- /dev/null +++ b/tests/fixtures/use-cache/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Use cache App', + description: 'Description for Use cache Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/use-cache/next-env.d.ts b/tests/fixtures/use-cache/next-env.d.ts new file mode 100644 index 0000000000..1b3be0840f --- /dev/null +++ b/tests/fixtures/use-cache/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/fixtures/use-cache/next.config.js b/tests/fixtures/use-cache/next.config.js new file mode 100644 index 0000000000..2dfab2319a --- /dev/null +++ b/tests/fixtures/use-cache/next.config.js @@ -0,0 +1,35 @@ +const INFINITE_CACHE = 0xfffffffe + +const ONE_YEAR = 365 * 24 * 60 * 60 + +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + useCache: true, + cacheLife: { + '5seconds': { + stale: 5, + revalidate: 5, + expire: INFINITE_CACHE, + }, + '10seconds': { + stale: 10, + revalidate: 10, + expire: INFINITE_CACHE, + }, + '1year': { + stale: ONE_YEAR, + revalidate: ONE_YEAR, + expire: INFINITE_CACHE, + }, + }, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/use-cache/package.json b/tests/fixtures/use-cache/package.json new file mode 100644 index 0000000000..0e68b6b536 --- /dev/null +++ b/tests/fixtures/use-cache/package.json @@ -0,0 +1,23 @@ +{ + "name": "use-cache", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@types/react": "19.1.2" + }, + "test": { + "dependencies": { + "next": ">=15.3.0-canary.13" + } + } +} diff --git a/tests/fixtures/use-cache/tsconfig.json b/tests/fixtures/use-cache/tsconfig.json new file mode 100644 index 0000000000..1d4f624eff --- /dev/null +++ b/tests/fixtures/use-cache/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/tests/integration/use-cache.test.ts b/tests/integration/use-cache.test.ts new file mode 100644 index 0000000000..16796fd547 --- /dev/null +++ b/tests/integration/use-cache.test.ts @@ -0,0 +1,429 @@ +import { CheerioAPI, load } from 'cheerio' +import { getLogger } from 'lambda-local' +import { v4 } from 'uuid' +import { beforeAll, describe, expect, test, vi } from 'vitest' +import { type FixtureTestContext } from '../utils/contexts.js' +import { createFixture, loadSandboxedFunction, runPlugin } from '../utils/fixture.js' +import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' +import { InvokeFunctionResult } from '../utils/lambda-helpers.mjs' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' + +function compareDates( + $response1: CheerioAPI, + $response2: CheerioAPI, + testid: string, + shouldBeEqual: boolean, + diffHelper: (a: string, b: string) => string | undefined, +) { + const selector = `[data-testid="${testid}"]` + + const data1 = $response1(selector).text() + const data2 = $response2(selector).text() + + if (!data1 || !data2) { + return { + isExpected: false, + msg: `Missing or empty data-testid="${testid}" in one of the responses`, + } + } + + const isEqual = data1 === data2 + const isExpected = isEqual === shouldBeEqual + + return { + isExpected, + msg: isExpected + ? null + : shouldBeEqual + ? `Expected ${testid} to be equal, but got different values:\n${diffHelper(data1, data2)}` + : `Expected ${testid} NOT to be equal, but got same value: "${data1}`, + } +} + +type ExpectedCachingBehaviorDefinition = { + getDataTimeShouldShouldBeEqual: boolean + resultWrapperComponentTimeShouldBeEqual: boolean + pageComponentTimeShouldBeEqual: boolean +} + +expect.extend({ + toBeCacheableResponse(response: Awaited) { + const netlifyCacheControlHeader = response.headers['netlify-cdn-cache-control'] + + if (typeof netlifyCacheControlHeader !== 'string') { + return { + pass: false, + message: () => + `Expected 'netlify-cdn-cache-control' response header to be a string. Got ${netlifyCacheControlHeader} (${typeof netlifyCacheControlHeader}).`, + } + } + + const isCacheable = Boolean(netlifyCacheControlHeader.match(/(max-age|s-maxage)(?!=0)/)) + + return { + pass: isCacheable, + message: () => + `Expected ${netlifyCacheControlHeader} to${this.isNot ? ' not' : ''} be cacheable`, + } + }, + toHaveResponseCacheTag(response: Awaited, tag: string) { + const netlifyCacheTag = response.headers['netlify-cache-tag'] + if (typeof netlifyCacheTag !== 'string') { + return { + pass: false, + message: () => + `Expected 'netlify-cache-tag' response header to be a string. Got ${netlifyCacheTag} (${typeof netlifyCacheTag}).`, + } + } + const containsTag = Boolean(netlifyCacheTag.split(',').find((t) => t.trim() === tag)) + + return { + pass: containsTag, + message: () => `Expected ${netlifyCacheTag} to${this.isNot ? ' not' : ''} have "${tag}" tag`, + } + }, + toHaveExpectedCachingBehavior( + response1: Awaited, + response2: Awaited, + { + getDataTimeShouldShouldBeEqual, + resultWrapperComponentTimeShouldBeEqual, + pageComponentTimeShouldBeEqual, + }: ExpectedCachingBehaviorDefinition, + ) { + const $response1 = load(response1.body) + const $response2 = load(response2.body) + + const getDataComparison = compareDates( + $response1, + $response2, + 'getData-time', + getDataTimeShouldShouldBeEqual, + this.utils.diff, + ) + const resultComponentComparison = compareDates( + $response1, + $response2, + 'ResultWrapperComponent-time', + resultWrapperComponentTimeShouldBeEqual, + this.utils.diff, + ) + const pageComponentComparison = compareDates( + $response1, + $response2, + 'PageComponent-time', + pageComponentTimeShouldBeEqual, + this.utils.diff, + ) + + return { + pass: + getDataComparison.isExpected && + resultComponentComparison.isExpected && + pageComponentComparison.isExpected, + message: () => + [getDataComparison.msg, resultComponentComparison.msg, pageComponentComparison.msg] + .filter(Boolean) + .join('\n\n'), + } + }, +}) + +interface CustomMatchers { + toBeCacheableResponse(): R + toHaveResponseCacheTag(tag: string): R + toHaveExpectedCachingBehavior( + response2: Awaited, + expectations: ExpectedCachingBehaviorDefinition, + ): R +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} +} + +// Disable the verbose logging of the lambda-local runtime +getLogger().level = 'alert' + +let ctx: FixtureTestContext +beforeAll(async () => { + ctx = { + deployID: generateRandomObjectID(), + siteID: v4(), + } as FixtureTestContext + + vi.stubEnv('SITE_ID', ctx.siteID) + vi.stubEnv('DEPLOY_ID', ctx.deployID) + vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'fake-token') + await startMockBlobStore(ctx as FixtureTestContext) + + await createFixture('use-cache', ctx) + await runPlugin(ctx) +}) + +// only supporting latest variant (https://github.com/vercel/next.js/pull/76687) +// first released in v15.3.0-canary.13 so we should not run tests on older next versions +describe.skipIf(!nextVersionSatisfies('>=15.3.0-canary.13'))('use cache', () => { + describe('default (in-memory cache entries, shared tag manifests)', () => { + for (const { + expectedCachingBehaviorWhenUseCacheRegenerates, + useCacheLocationLabel, + useCacheLocationPathSegment, + useCacheTagPrefix, + } of [ + { + useCacheLocationLabel: "'use cache' in data fetching function", + useCacheLocationPathSegment: 'use-cache-data', + useCacheTagPrefix: 'data', + expectedCachingBehaviorWhenUseCacheRegenerates: { + // getData function has 'use cache' so it should report same generation time, everything else is dynamically regenerated on each request + getDataTimeShouldShouldBeEqual: true, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }, + }, + { + useCacheLocationLabel: "'use cache' in react non-page component", + useCacheLocationPathSegment: 'use-cache-component', + useCacheTagPrefix: 'component', + expectedCachingBehaviorWhenUseCacheRegenerates: { + // has 'use cache' so it should report same generation time, everything else is dynamically regenerated on each request + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: true, + pageComponentTimeShouldBeEqual: false, + }, + }, + { + useCacheLocationLabel: "'use cache' in react page component", + useCacheLocationPathSegment: 'use-cache-page', + useCacheTagPrefix: 'page', + expectedCachingBehaviorWhenUseCacheRegenerates: { + // has 'use cache' so it should report same generation time for everything as this is entry point + getDataTimeShouldShouldBeEqual: true, + resultWrapperComponentTimeShouldBeEqual: true, + pageComponentTimeShouldBeEqual: true, + }, + }, + ]) { + describe(useCacheLocationLabel, () => { + describe('dynamic page (not using response cache)', () => { + describe('TTL=1 year', () => { + const routeRoot = `default/${useCacheLocationPathSegment}/dynamic/ttl-1year` + + test('subsequent invocations on same lambda return same result', async () => { + const url = `${routeRoot}/same-lambda` + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + const call1 = await invokeFunction({ url }) + expect(call1).not.toBeCacheableResponse() + + const call2 = await invokeFunction({ url }) + expect(call2).toHaveExpectedCachingBehavior( + call1, + expectedCachingBehaviorWhenUseCacheRegenerates, + ) + }) + + test('tag invalidation works on same lambda', async () => { + const url = `${routeRoot}/same-lambda-tag-invalidation` + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + const call1 = await invokeFunction({ url }) + expect(call1).not.toBeCacheableResponse() + + await invokeFunction({ url: `/api/revalidate/${useCacheTagPrefix}/${url}` }) + + const call2 = await invokeFunction({ url }) + expect(call2).toHaveExpectedCachingBehavior(call1, { + // getData function has 'use cache', but it was on-demand revalidated so everything should be fresh + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }) + }) + + test('invocations on different lambdas return different results', async () => { + const url = `${routeRoot}/different-lambdas` + + const { invokeFunction: invokeFunctionLambda1 } = await loadSandboxedFunction(ctx) + const { invokeFunction: invokeFunctionLambda2 } = await loadSandboxedFunction(ctx) + + const call1 = await invokeFunctionLambda1({ url }) + expect(call1).not.toBeCacheableResponse() + + const call2 = await invokeFunctionLambda2({ url }) + expect(call2).toHaveExpectedCachingBehavior(call1, { + // default cache is in-memory so we expect lambdas not to share data + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }) + }) + + test('invalidating tag on one lambda result in invalidating them on all lambdas', async () => { + const url = `${routeRoot}/different-lambdas-tag-invalidation` + + const { invokeFunction: invokeFunctionLambda1 } = await loadSandboxedFunction(ctx) + const { invokeFunction: invokeFunctionLambda2 } = await loadSandboxedFunction(ctx) + + const call1 = await invokeFunctionLambda1({ url }) + expect(call1).not.toBeCacheableResponse() + + await invokeFunctionLambda2({ url: `/api/revalidate/${useCacheTagPrefix}/${url}` }) + + const call2 = await invokeFunctionLambda1({ url }) + expect(call2).toHaveExpectedCachingBehavior(call1, { + // invalidation done by lambda2 should invalidate lambda1 as well + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }) + }) + }) + + describe('TTL=5 seconds', () => { + const routeRoot = `default/${useCacheLocationPathSegment}/dynamic/ttl-5seconds` + + test('regenerate after 5 seconds', async () => { + const url = `${routeRoot}/same-lambda` + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + const call1 = await invokeFunction({ url }) + expect(call1).not.toBeCacheableResponse() + + const call2 = await invokeFunction({ url }) + // making sure that setup is correct first and that caching is enabled + expect(call2).toHaveExpectedCachingBehavior( + call1, + expectedCachingBehaviorWhenUseCacheRegenerates, + ) + + // wait for cache to expire + await new Promise((resolve) => setTimeout(resolve, 5000)) + + const call3 = await invokeFunction({ url }) + expect(call3).toHaveExpectedCachingBehavior(call2, { + // cache should expire and fresh content should be generated + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }) + }) + }) + }) + + describe('static page (using response cache)', () => { + for (const { isPrerendered, isPrerenderedTestLabel, isPrerenderedPathSegment } of [ + { + isPrerendered: true, + isPrerenderedTestLabel: 'prerendered', + isPrerenderedPathSegment: 'prerendered', + }, + { + isPrerendered: false, + isPrerenderedTestLabel: 'not prerendered', + isPrerenderedPathSegment: 'not-prerendered', + }, + ]) { + describe(isPrerenderedTestLabel, () => { + describe('page TTL=1 year, use cache TTL=1 year', () => { + const routeRoot = `default/${useCacheLocationPathSegment}/static/ttl-1year` + + test('response cache continue to work and skips use cache handling', async () => { + const url = `${routeRoot}/${isPrerenderedPathSegment}` + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + if (isPrerendered) { + const callPrerenderedStale = await invokeFunction({ url }) + expect(callPrerenderedStale.headers['cache-status']).toBe( + '"Next.js"; hit; fwd=stale', + ) + } + + const call1 = await invokeFunction({ url }) + expect(call1).toBeCacheableResponse() + expect(call1).toHaveResponseCacheTag(`${useCacheTagPrefix}/${url}`) + + const call2 = await invokeFunction({ url }) + + expect(call2).toHaveExpectedCachingBehavior(call1, { + // response is served from response cache and `use cache` is not even actually used + getDataTimeShouldShouldBeEqual: true, + resultWrapperComponentTimeShouldBeEqual: true, + pageComponentTimeShouldBeEqual: true, + }) + + // test response invalidation + await invokeFunction({ + url: `/api/revalidate/${useCacheTagPrefix}/${url}`, + }) + + const call3 = await invokeFunction({ url }) + + expect(call3).toHaveExpectedCachingBehavior(call2, { + // invalidation shot result in everything changing + getDataTimeShouldShouldBeEqual: false, + resultWrapperComponentTimeShouldBeEqual: false, + pageComponentTimeShouldBeEqual: false, + }) + }) + }) + + describe('page TTL=5 seconds / use cache TTL=10 seconds', () => { + const routeRoot = `default/${useCacheLocationPathSegment}/static/ttl-10seconds` + + test('both response cache and use cache respect their TTLs', async () => { + const url = `${routeRoot}/${isPrerenderedPathSegment}` + + const { invokeFunction } = await loadSandboxedFunction(ctx) + + if (isPrerendered) { + const callPrerenderedStale = await invokeFunction({ url }) + expect(callPrerenderedStale.headers['cache-status']).toBe( + '"Next.js"; hit; fwd=stale', + ) + } + + const call1 = await invokeFunction({ url }) + expect(call1).toBeCacheableResponse() + expect(call1).toHaveResponseCacheTag(`${useCacheTagPrefix}/${url}`) + + const call2 = await invokeFunction({ url }) + + expect(call2).toHaveExpectedCachingBehavior(call1, { + // response is served from response cache and `use cache` is not even actually used + getDataTimeShouldShouldBeEqual: true, + resultWrapperComponentTimeShouldBeEqual: true, + pageComponentTimeShouldBeEqual: true, + }) + + // wait for use cache to expire + await new Promise((resolve) => setTimeout(resolve, 5000)) + + const call3 = await invokeFunction({ url }) + expect(call3).toHaveExpectedCachingBehavior(call2, { + // still stale content on first request after invalidation + getDataTimeShouldShouldBeEqual: true, + resultWrapperComponentTimeShouldBeEqual: true, + pageComponentTimeShouldBeEqual: true, + }) + + const call4 = await invokeFunction({ url }) + // fresh response, but use cache should still use cached data + expect(call4).toHaveExpectedCachingBehavior( + call3, + expectedCachingBehaviorWhenUseCacheRegenerates, + ) + }) + }) + }) + } + }) + }) + } + }) +})