diff --git a/.changeset/rich-bears-kick.md b/.changeset/rich-bears-kick.md new file mode 100644 index 00000000..5bacd8bc --- /dev/null +++ b/.changeset/rich-bears-kick.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +test: sync e2e with aws diff --git a/examples/e2e/app-router/app/isr/dynamic-params-false/[id]/page.tsx b/examples/e2e/app-router/app/isr/dynamic-params-false/[id]/page.tsx new file mode 100644 index 00000000..06542a23 --- /dev/null +++ b/examples/e2e/app-router/app/isr/dynamic-params-false/[id]/page.tsx @@ -0,0 +1,34 @@ +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams +export const dynamicParams = false; // or true, to make it try SSR unknown paths + +const POSTS = Array.from({ length: 20 }, (_, i) => ({ + id: String(i + 1), + title: `Post ${i + 1}`, + content: `This is post ${i + 1}`, +})); + +async function fakeGetPostsFetch() { + return POSTS.slice(0, 10); +} + +async function fakeGetPostFetch(id: string) { + return POSTS.find((post) => post.id === id); +} + +export async function generateStaticParams() { + const fakePosts = await fakeGetPostsFetch(); + return fakePosts.map((post) => ({ + id: post.id, + })); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const post = await fakeGetPostFetch(id); + return ( + + {post?.title} + {post?.content} + + ); +} diff --git a/examples/e2e/app-router/app/isr/dynamic-params-true/[id]/page.tsx b/examples/e2e/app-router/app/isr/dynamic-params-true/[id]/page.tsx new file mode 100644 index 00000000..649a6c53 --- /dev/null +++ b/examples/e2e/app-router/app/isr/dynamic-params-true/[id]/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from "next/navigation"; + +// We'll prerender only the params from `generateStaticParams` at build time. +// If a request comes in for a path that hasn't been generated, +// Next.js will server-render the page on-demand. +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams +export const dynamicParams = true; // or false, to 404 on unknown paths + +const POSTS = Array.from({ length: 20 }, (_, i) => ({ + id: String(i + 1), + title: `Post ${i + 1}`, + content: `This is post ${i + 1}`, +})); + +async function fakeGetPostsFetch() { + return POSTS.slice(0, 10); +} + +async function fakeGetPostFetch(id: string) { + return POSTS.find((post) => post.id === id); +} + +export async function generateStaticParams() { + const fakePosts = await fakeGetPostsFetch(); + return fakePosts.map((post) => ({ + id: post.id, + })); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const post = await fakeGetPostFetch(id); + if (Number(id) === 1337) { + throw new Error("This is an error!"); + } + if (!post) { + notFound(); + } + return ( + + {post.title} + {post.content} + + ); +} diff --git a/examples/e2e/app-router/e2e/isr.test.ts b/examples/e2e/app-router/e2e/isr.test.ts index 61170630..72e99c84 100644 --- a/examples/e2e/app-router/e2e/isr.test.ts +++ b/examples/e2e/app-router/e2e/isr.test.ts @@ -98,3 +98,59 @@ test("Incremental Static Regeneration with data cache", async ({ page }) => { expect(originalCachedDate).toEqual(finalCachedDate); expect(originalFetchedDate).toEqual(finalFetchedDate); }); + +test.describe("dynamicParams set to true", () => { + test("should be HIT on a path that was prebuilt", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-true/1"); + expect(res?.status()).toEqual(200); + expect(res?.headers()["x-nextjs-cache"]).toEqual("HIT"); + const title = await page.getByTestId("title").textContent(); + const content = await page.getByTestId("content").textContent(); + expect(title).toEqual("Post 1"); + expect(content).toEqual("This is post 1"); + }); + + // In `next start` this test would fail on subsequent requests because `x-nextjs-cache` would be `HIT` + // However, once deployed to AWS, Cloudfront will cache `MISS` + // We are gonna skip this one for now, turborepo caching can cause this page to be STALE once deployed + test.skip("should SSR on a path that was not prebuilt", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-true/11"); + expect(res?.headers()["x-nextjs-cache"]).toEqual("MISS"); + const title = await page.getByTestId("title").textContent(); + const content = await page.getByTestId("content").textContent(); + expect(title).toEqual("Post 11"); + expect(content).toEqual("This is post 11"); + }); + + test("should 404 when you call notFound", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-true/21"); + expect(res?.status()).toEqual(404); + expect(res?.headers()["cache-control"]).toBe("private, no-cache, no-store, max-age=0, must-revalidate"); + await expect(page.getByText("404")).toBeAttached(); + }); + + test("should 500 for a path that throws an error", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-true/1337"); + expect(res?.status()).toEqual(500); + expect(res?.headers()["cache-control"]).toBe("private, no-cache, no-store, max-age=0, must-revalidate"); + }); +}); + +test.describe("dynamicParams set to false", () => { + test("should be HIT on a path that was prebuilt", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-false/1"); + expect(res?.status()).toEqual(200); + expect(res?.headers()["x-nextjs-cache"]).toEqual("HIT"); + const title = await page.getByTestId("title").textContent(); + const content = await page.getByTestId("content").textContent(); + expect(title).toEqual("Post 1"); + expect(content).toEqual("This is post 1"); + }); + + test("should 404 for a path that is not found", async ({ page }) => { + const res = await page.goto("/isr/dynamic-params-false/11"); + expect(res?.status()).toEqual(404); + expect(res?.headers()["cache-control"]).toBe("private, no-cache, no-store, max-age=0, must-revalidate"); + await expect(page.getByText("404")).toBeAttached(); + }); +}); diff --git a/examples/e2e/app-router/e2e/middleware.redirect.test.ts b/examples/e2e/app-router/e2e/middleware.redirect.test.ts index 3518da9b..7855c313 100644 --- a/examples/e2e/app-router/e2e/middleware.redirect.test.ts +++ b/examples/e2e/app-router/e2e/middleware.redirect.test.ts @@ -1,4 +1,11 @@ import { expect, test } from "@playwright/test"; +import { validateMd5 } from "../../utils"; + +/* + * `curl -s https://opennext.js.org/share.png | md5sum` + * This is the MD5 hash of the image. It is used to validate the image content. + */ +const OPENNEXT_PNG_MD5 = "405f45cc3397b09717a13ebd6f1e027b"; test("Middleware Redirect", async ({ page, context }) => { await page.goto("/"); @@ -18,3 +25,14 @@ test("Middleware Redirect", async ({ page, context }) => { el = page.getByText("Redirect Destination", { exact: true }); await expect(el).toBeVisible(); }); + +test("Middleware Rewrite External Image", async ({ page }) => { + await page.goto("/rewrite-external"); + page.on("response", async (response) => { + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toBe("image/png"); + expect(response.headers()["cache-control"]).toBe("max-age=600"); + const bodyBuffer = await response.body(); + expect(validateMd5(bodyBuffer, OPENNEXT_PNG_MD5)).toBe(true); + }); +}); diff --git a/examples/e2e/app-router/middleware.ts b/examples/e2e/app-router/middleware.ts index 923fcaee..5a067e3b 100644 --- a/examples/e2e/app-router/middleware.ts +++ b/examples/e2e/app-router/middleware.ts @@ -24,7 +24,11 @@ export function middleware(request: NextRequest) { }, }); } - const requestHeaders = new Headers(); + if (path === "/rewrite-external") { + const u = new URL("https://opennext.js.org/share.png"); + return NextResponse.rewrite(u); + } + const requestHeaders = new Headers(request.headers); // Setting the Request Headers, this should be available in RSC requestHeaders.set("request-header", "request-header"); requestHeaders.set("search-params", `mw/${request.nextUrl.searchParams.get("searchParams") || ""}`); diff --git a/examples/e2e/pages-router/e2e/streaming.test.ts b/examples/e2e/pages-router/e2e/streaming.test.ts new file mode 100644 index 00000000..7f6d3e1d --- /dev/null +++ b/examples/e2e/pages-router/e2e/streaming.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +const SADE_SMOOTH_OPERATOR_LYRIC = `Diamond life, lover boy +He move in space with minimum waste and maximum joy +City lights and business nights +When you require streetcar desire for higher heights +No place for beginners or sensitive hearts +When sentiment is left to chance +No place to be ending but somewhere to start +No need to ask, he's a smooth operator +Smooth operator, smooth operator +Smooth operator`; + +test("streaming should work in api route", async ({ page }) => { + await page.goto("/sse"); + + // wait for first line to be present + await page.getByTestId("line").first().waitFor(); + const initialLines = await page.getByTestId("line").count(); + // fail if all lines appear at once + // this is a safeguard to ensure that the response is streamed and not buffered all at once + expect(initialLines).toBe(1); + + const seenLines: Array<{ line: string; time: number }> = []; + const startTime = Date.now(); + + // we loop until we see all lines + while (seenLines.length < SADE_SMOOTH_OPERATOR_LYRIC.split("\n").length) { + const lines = await page.getByTestId("line").all(); + if (lines.length > seenLines.length) { + expect(lines.length).toBe(seenLines.length + 1); + const newLine = lines[lines.length - 1]; + seenLines.push({ + line: await newLine.innerText(), + time: Date.now() - startTime, + }); + } + // wait for a bit before checking again + await page.waitForTimeout(200); + } + + expect(seenLines.map((n) => n.line)).toEqual(SADE_SMOOTH_OPERATOR_LYRIC.split("\n")); + for (let i = 1; i < seenLines.length; i++) { + expect(seenLines[i].time - seenLines[i - 1].time).toBeGreaterThan(500); + } + + await expect(page.getByTestId("video")).toBeVisible(); +}); diff --git a/examples/e2e/pages-router/src/pages/api/streaming/index.ts b/examples/e2e/pages-router/src/pages/api/streaming/index.ts new file mode 100644 index 00000000..dfec3d14 --- /dev/null +++ b/examples/e2e/pages-router/src/pages/api/streaming/index.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const SADE_SMOOTH_OPERATOR_LYRIC = `Diamond life, lover boy +He move in space with minimum waste and maximum joy +City lights and business nights +When you require streetcar desire for higher heights +No place for beginners or sensitive hearts +When sentiment is left to chance +No place to be ending but somewhere to start +No need to ask, he's a smooth operator +Smooth operator, smooth operator +Smooth operator`; + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Transfer-Encoding", "chunked"); + + res.write(`data: ${JSON.stringify({ type: "start", model: "ai-lyric-model" })}\n\n`); + await sleep(1000); + + const lines = SADE_SMOOTH_OPERATOR_LYRIC.split("\n"); + for (const line of lines) { + res.write(`data: ${JSON.stringify({ type: "content", body: line })}\n\n`); + await sleep(1000); + } + + res.write(`data: ${JSON.stringify({ type: "complete" })}\n\n`); + + res.end(); +} diff --git a/examples/e2e/pages-router/src/pages/sse/index.tsx b/examples/e2e/pages-router/src/pages/sse/index.tsx new file mode 100644 index 00000000..dbc5f8ee --- /dev/null +++ b/examples/e2e/pages-router/src/pages/sse/index.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Event = { + type: "start" | "content" | "complete"; + model?: string; + body?: string; +}; + +export default function SSE() { + const [events, setEvents] = useState([]); + const [finished, setFinished] = useState(false); + + useEffect(() => { + const e = new EventSource("/api/streaming"); + + e.onmessage = (msg) => { + console.log(msg); + try { + const data = JSON.parse(msg.data) as Event; + if (data.type === "complete") { + e.close(); + setFinished(true); + } + if (data.type === "content") { + setEvents((prev) => prev.concat(data)); + } + } catch (err) { + console.error(err, msg); + } + }; + }, []); + + return ( + + + Sade - Smooth Operator + + + {events.map((e, i) => ( + + {e.body} + + ))} + + {finished && ( + + )} + + ); +}
{post?.content}
{post.content}
+ {e.body} +