From e4285a7178caccec2eb999e7a9520d580a26ba80 Mon Sep 17 00:00:00 2001
From: Victor Berchet <victor@suumit.com>
Date: Tue, 28 Jan 2025 22:17:05 +0100
Subject: [PATCH] test: import app-router e2e tests from aws (#289)

---
 examples/e2e/app-router/.gitignore            |  7 ++
 examples/e2e/app-router/e2e/after.test.ts     | 29 ++++++
 examples/e2e/app-router/e2e/api.test.ts       | 29 ++++++
 .../app-router/e2e/config.redirect.test.ts    | 75 +++++++++++++++
 examples/e2e/app-router/e2e/headers.test.ts   | 27 ++++++
 examples/e2e/app-router/e2e/host.test.ts      | 11 +++
 .../app-router/e2e/image-optimization.test.ts | 18 ++++
 .../e2e/app-router/e2e/isr.revalidate.test.ts | 13 +++
 examples/e2e/app-router/e2e/isr.test.ts       | 63 +++++++++++++
 .../app-router/e2e/middleware.cookies.test.ts | 12 +++
 .../e2e/middleware.redirect.test.ts           | 20 ++++
 .../app-router/e2e/middleware.rewrite.test.ts | 16 ++++
 examples/e2e/app-router/e2e/modals.test.ts    | 18 ++++
 examples/e2e/app-router/e2e/og.test.ts        | 39 ++++++++
 examples/e2e/app-router/e2e/parallel.test.ts  | 42 +++++++++
 .../e2e/app-router/e2e/playwright.config.ts   | 54 +++++++++++
 examples/e2e/app-router/e2e/query.test.ts     | 19 ++++
 .../e2e/app-router/e2e/revalidateTag.test.ts  | 94 +++++++++++++++++++
 .../e2e/app-router/e2e/serverActions.test.ts  | 22 +++++
 examples/e2e/app-router/e2e/sse.test.ts       | 45 +++++++++
 examples/e2e/app-router/e2e/ssr.test.ts       | 37 ++++++++
 examples/e2e/app-router/e2e/trailing.test.ts  | 20 ++++
 examples/e2e/app-router/package.json          |  6 +-
 examples/e2e/app-router/tsconfig.json         |  2 +-
 examples/e2e/utils.ts                         |  5 +
 pnpm-lock.yaml                                | 25 ++---
 26 files changed, 734 insertions(+), 14 deletions(-)
 create mode 100644 examples/e2e/app-router/e2e/after.test.ts
 create mode 100644 examples/e2e/app-router/e2e/api.test.ts
 create mode 100644 examples/e2e/app-router/e2e/config.redirect.test.ts
 create mode 100644 examples/e2e/app-router/e2e/headers.test.ts
 create mode 100644 examples/e2e/app-router/e2e/host.test.ts
 create mode 100644 examples/e2e/app-router/e2e/image-optimization.test.ts
 create mode 100644 examples/e2e/app-router/e2e/isr.revalidate.test.ts
 create mode 100644 examples/e2e/app-router/e2e/isr.test.ts
 create mode 100644 examples/e2e/app-router/e2e/middleware.cookies.test.ts
 create mode 100644 examples/e2e/app-router/e2e/middleware.redirect.test.ts
 create mode 100644 examples/e2e/app-router/e2e/middleware.rewrite.test.ts
 create mode 100644 examples/e2e/app-router/e2e/modals.test.ts
 create mode 100644 examples/e2e/app-router/e2e/og.test.ts
 create mode 100644 examples/e2e/app-router/e2e/parallel.test.ts
 create mode 100644 examples/e2e/app-router/e2e/playwright.config.ts
 create mode 100644 examples/e2e/app-router/e2e/query.test.ts
 create mode 100644 examples/e2e/app-router/e2e/revalidateTag.test.ts
 create mode 100644 examples/e2e/app-router/e2e/serverActions.test.ts
 create mode 100644 examples/e2e/app-router/e2e/sse.test.ts
 create mode 100644 examples/e2e/app-router/e2e/ssr.test.ts
 create mode 100644 examples/e2e/app-router/e2e/trailing.test.ts
 create mode 100644 examples/e2e/utils.ts

diff --git a/examples/e2e/app-router/.gitignore b/examples/e2e/app-router/.gitignore
index 61cbd98f..1400f68e 100644
--- a/examples/e2e/app-router/.gitignore
+++ b/examples/e2e/app-router/.gitignore
@@ -34,3 +34,10 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+
+# playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+
diff --git a/examples/e2e/app-router/e2e/after.test.ts b/examples/e2e/app-router/e2e/after.test.ts
new file mode 100644
index 00000000..c41b4c05
--- /dev/null
+++ b/examples/e2e/app-router/e2e/after.test.ts
@@ -0,0 +1,29 @@
+import { expect, test } from "@playwright/test";
+
+test("Next after", async ({ request }) => {
+  const initialSSG = await request.get("/api/after/ssg");
+  expect(initialSSG.status()).toEqual(200);
+  const initialSSGJson = await initialSSG.json();
+
+  // We then fire a post request that will revalidate the SSG page 5 seconds after, but should respond immediately
+  const dateNow = Date.now();
+  const revalidateSSG = await request.post("/api/after/revalidate");
+  expect(revalidateSSG.status()).toEqual(200);
+  const revalidateSSGJson = await revalidateSSG.json();
+  expect(revalidateSSGJson.success).toEqual(true);
+  // This request should take less than 5 seconds to respond
+  expect(Date.now() - dateNow).toBeLessThan(5000);
+
+  // We want to immediately check if the SSG page has been revalidated, it should not have been
+  const notRevalidatedSSG = await request.get("/api/after/ssg");
+  expect(notRevalidatedSSG.status()).toEqual(200);
+  const notRevalidatedSSGJson = await notRevalidatedSSG.json();
+  expect(notRevalidatedSSGJson.date).toEqual(initialSSGJson.date);
+
+  // We then wait for 5 seconds to ensure the SSG page has been revalidated
+  await new Promise((resolve) => setTimeout(resolve, 5000));
+  const revalidatedSSG = await request.get("/api/after/ssg");
+  expect(revalidatedSSG.status()).toEqual(200);
+  const revalidatedSSGJson = await revalidatedSSG.json();
+  expect(revalidatedSSGJson.date).not.toEqual(initialSSGJson.date);
+});
diff --git a/examples/e2e/app-router/e2e/api.test.ts b/examples/e2e/app-router/e2e/api.test.ts
new file mode 100644
index 00000000..670f1b05
--- /dev/null
+++ b/examples/e2e/app-router/e2e/api.test.ts
@@ -0,0 +1,29 @@
+import { expect, test } from "@playwright/test";
+
+test("API call from client", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "/API" }).click();
+
+  await page.waitForURL("/api");
+
+  let el = page.getByText("API: N/A");
+  await expect(el).toBeVisible();
+
+  await page.getByRole("button", { name: "Call /api/client" }).click();
+  el = page.getByText('API: { "hello": "client" }');
+  await expect(el).toBeVisible();
+});
+
+test("API call from middleware", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "/API" }).click();
+
+  await page.waitForURL("/api");
+
+  let el = page.getByText("API: N/A");
+  await expect(el).toBeVisible();
+
+  await page.getByRole("button", { name: "Call /api/middleware" }).click();
+  el = page.getByText('API: { "hello": "middleware" }');
+  await expect(el).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/config.redirect.test.ts b/examples/e2e/app-router/e2e/config.redirect.test.ts
new file mode 100644
index 00000000..d328d04e
--- /dev/null
+++ b/examples/e2e/app-router/e2e/config.redirect.test.ts
@@ -0,0 +1,75 @@
+import { expect, test } from "@playwright/test";
+/**
+ * This tests that the "redirect" config in next.config.js works
+ * 
+ * redirects: () => {
+    return [
+      {
+        source: "/next-config-redirect",
+        destination: "/config-redirect",
+        permanent: true,
+        missing: [{ type: "cookie", key: "missing-cookie" }],
+      },
+    ];
+  },
+ */
+test.describe("Next Config Redirect", () => {
+  test("Missing cookies", async ({ page }) => {
+    await page.goto("/");
+    await page.goto("/next-config-redirect-missing");
+
+    await page.waitForURL("/config-redirect?missing=true");
+
+    const el = page.getByText("I was redirected from next.config.js", {
+      exact: true,
+    });
+    await expect(el).toBeVisible();
+  });
+  test("Not missing cookies", async ({ page }) => {
+    await page.goto("/");
+    await page.goto("/next-config-redirect-not-missing");
+
+    // the cookie was not missing, so no redirects
+    await page.waitForURL("/next-config-redirect-not-missing");
+
+    const el = page.getByText("This page could not be found.", {
+      exact: true,
+    });
+    await expect(el).toBeVisible();
+  });
+  test("Has cookies", async ({ page }) => {
+    await page.goto("/");
+    await page.goto("/next-config-redirect-has");
+
+    await page.waitForURL("/config-redirect?has=true");
+
+    const el = page.getByText("I was redirected from next.config.js", {
+      exact: true,
+    });
+    await expect(el).toBeVisible();
+  });
+  test("Has cookies with value", async ({ page }) => {
+    await page.goto("/");
+    await page.goto("/next-config-redirect-has-with-value");
+
+    await page.waitForURL("/config-redirect?hasWithValue=true");
+
+    const el = page.getByText("I was redirected from next.config.js", {
+      exact: true,
+    });
+    await expect(el).toBeVisible();
+  });
+  test("Has cookies with bad value", async ({ page }) => {
+    await page.goto("/");
+    await page.goto("/next-config-redirect-has-with-bad-value");
+
+    // did not redirect
+    await page.waitForURL("/next-config-redirect-has-with-bad-value");
+
+    // 404 not found
+    const el = page.getByText("This page could not be found.", {
+      exact: true,
+    });
+    await expect(el).toBeVisible();
+  });
+});
diff --git a/examples/e2e/app-router/e2e/headers.test.ts b/examples/e2e/app-router/e2e/headers.test.ts
new file mode 100644
index 00000000..f992c61f
--- /dev/null
+++ b/examples/e2e/app-router/e2e/headers.test.ts
@@ -0,0 +1,27 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * Tests that the headers are available in RSC and response headers
+ */
+test("Headers", async ({ page }) => {
+  const responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/headers");
+
+  const response = await responsePromise;
+  // Response header should be set
+  const headers = response.headers();
+  expect(headers["response-header"]).toEqual("response-header");
+
+  // The next.config.js headers should be also set in response
+  expect(headers["e2e-headers"]).toEqual("next.config.js");
+
+  // Request header should be available in RSC
+  const el = page.getByText("request-header");
+  await expect(el).toBeVisible();
+
+  // Both these headers should not be present cause poweredByHeader is false in appRouter
+  expect(headers["x-powered-by"]).toBeFalsy();
+  expect(headers["x-opennext"]).toBeFalsy();
+});
diff --git a/examples/e2e/app-router/e2e/host.test.ts b/examples/e2e/app-router/e2e/host.test.ts
new file mode 100644
index 00000000..98141106
--- /dev/null
+++ b/examples/e2e/app-router/e2e/host.test.ts
@@ -0,0 +1,11 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * Tests that the request.url is the deployed host and not localhost
+ */
+test("Request.url is host", async ({ baseURL, page }) => {
+  await page.goto("/api/host");
+
+  const el = page.getByText(`{"url":"${baseURL}/api/host"}`);
+  await expect(el).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/image-optimization.test.ts b/examples/e2e/app-router/e2e/image-optimization.test.ts
new file mode 100644
index 00000000..b455c7f3
--- /dev/null
+++ b/examples/e2e/app-router/e2e/image-optimization.test.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("Image Optimization", async ({ page }) => {
+  await page.goto("/");
+
+  const imageResponsePromise = page.waitForResponse(/https%3A%2F%2Fopennext.js.org%2Farchitecture.png/);
+  await page.locator('[href="/image-optimization"]').click();
+  const imageResponse = await imageResponsePromise;
+
+  await page.waitForURL("/image-optimization");
+
+  const imageContentType = imageResponse.headers()["content-type"];
+  expect(imageContentType).toBe("image/webp");
+
+  const el = page.locator("img");
+  await expect(el).toHaveJSProperty("complete", true);
+  await expect(el).not.toHaveJSProperty("naturalWidth", 0);
+});
diff --git a/examples/e2e/app-router/e2e/isr.revalidate.test.ts b/examples/e2e/app-router/e2e/isr.revalidate.test.ts
new file mode 100644
index 00000000..3df688e0
--- /dev/null
+++ b/examples/e2e/app-router/e2e/isr.revalidate.test.ts
@@ -0,0 +1,13 @@
+import { expect, test } from "@playwright/test";
+
+test("Test revalidate", async ({ request }) => {
+  const result = await request.get("/api/isr");
+
+  expect(result.status()).toEqual(200);
+  const json = await result.json();
+  const body = json.body;
+
+  expect(json.status).toEqual(200);
+  expect(body.result).toEqual(true);
+  expect(body.cacheControl).toEqual("private, no-cache, no-store, max-age=0, must-revalidate");
+});
diff --git a/examples/e2e/app-router/e2e/isr.test.ts b/examples/e2e/app-router/e2e/isr.test.ts
new file mode 100644
index 00000000..934925e9
--- /dev/null
+++ b/examples/e2e/app-router/e2e/isr.test.ts
@@ -0,0 +1,63 @@
+import { expect, test } from "@playwright/test";
+
+test("Incremental Static Regeneration", async ({ page }) => {
+  test.setTimeout(45000);
+  await page.goto("/");
+  await page.locator("[href='/isr']").click();
+  // Load the page a couple times to regenerate ISR
+
+  let el = page.getByText("Time:");
+  // Track the static time
+  let time = await el.textContent();
+  let newTime: typeof time;
+  let tempTime = time;
+  do {
+    await page.waitForTimeout(1000);
+    await page.reload();
+    time = tempTime;
+    el = page.getByText("Time:");
+    newTime = await el.textContent();
+    tempTime = newTime;
+  } while (time !== newTime);
+  await page.reload();
+
+  await page.waitForTimeout(1000);
+  el = page.getByText("Time:");
+  const midTime = await el.textContent();
+  // Expect that the time is still stale
+  expect(midTime).toEqual(newTime);
+
+  // Wait 10 + 1 seconds for ISR to regenerate time
+  await page.waitForTimeout(11000);
+  let finalTime = newTime;
+  do {
+    await page.waitForTimeout(2000);
+    el = page.getByText("Time:");
+    finalTime = await el.textContent();
+    await page.reload();
+  } while (newTime === finalTime);
+
+  expect(newTime).not.toEqual(finalTime);
+});
+
+test("headers", async ({ page }) => {
+  let responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/isr");
+
+  while (true) {
+    const response = await responsePromise;
+    const headers = response.headers();
+
+    // this was set in middleware
+    if (headers["cache-control"] === "max-age=10, stale-while-revalidate=999") {
+      break;
+    }
+    await page.waitForTimeout(1000);
+    responsePromise = page.waitForResponse((response) => {
+      return response.status() === 200;
+    });
+    await page.reload();
+  }
+});
diff --git a/examples/e2e/app-router/e2e/middleware.cookies.test.ts b/examples/e2e/app-router/e2e/middleware.cookies.test.ts
new file mode 100644
index 00000000..9c92fe60
--- /dev/null
+++ b/examples/e2e/app-router/e2e/middleware.cookies.test.ts
@@ -0,0 +1,12 @@
+import { expect, test } from "@playwright/test";
+
+test("Cookies", async ({ page, context }) => {
+  await page.goto("/");
+
+  const cookies = await context.cookies();
+  const from = cookies.find(({ name }) => name === "from");
+  expect(from?.value).toEqual("middleware");
+
+  const love = cookies.find(({ name }) => name === "with");
+  expect(love?.value).toEqual("love");
+});
diff --git a/examples/e2e/app-router/e2e/middleware.redirect.test.ts b/examples/e2e/app-router/e2e/middleware.redirect.test.ts
new file mode 100644
index 00000000..3518da9b
--- /dev/null
+++ b/examples/e2e/app-router/e2e/middleware.redirect.test.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/test";
+
+test("Middleware Redirect", async ({ page, context }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "/Redirect" }).click();
+
+  // URL is immediately redirected
+  await page.waitForURL("/redirect-destination");
+  let el = page.getByText("Redirect Destination", { exact: true });
+  await expect(el).toBeVisible();
+
+  // Loading page should also redirect
+  await page.goto("/redirect");
+  await page.waitForURL("/redirect-destination");
+  expect(await context.cookies().then((res) => res.find((cookie) => cookie.name === "test")?.value)).toBe(
+    "success"
+  );
+  el = page.getByText("Redirect Destination", { exact: true });
+  await expect(el).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/middleware.rewrite.test.ts b/examples/e2e/app-router/e2e/middleware.rewrite.test.ts
new file mode 100644
index 00000000..43118768
--- /dev/null
+++ b/examples/e2e/app-router/e2e/middleware.rewrite.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from "@playwright/test";
+
+test("Middleware Rewrite", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "/Rewrite" }).click();
+
+  await page.waitForURL("/rewrite");
+  let el = page.getByText("Rewritten Destination", { exact: true });
+  await expect(el).toBeVisible();
+
+  // Loading page should also rewrite
+  await page.goto("/rewrite");
+  await page.waitForURL("/rewrite");
+  el = page.getByText("Rewritten Destination", { exact: true });
+  await expect(el).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/modals.test.ts b/examples/e2e/app-router/e2e/modals.test.ts
new file mode 100644
index 00000000..5a443024
--- /dev/null
+++ b/examples/e2e/app-router/e2e/modals.test.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("Route modal and interception", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "Albums" }).click();
+  await page.getByRole("link", { name: "Song: I'm never gonna give you up Year: 1965" }).click();
+
+  await page.waitForURL(`/albums/Hold%20Me%20In%20Your%20Arms/I'm%20never%20gonna%20give%20you%20up`);
+
+  const modal = page.getByText("Modal", { exact: true });
+  await expect(modal).toBeVisible();
+
+  // Reload the page to load non intercepted modal
+  await page.reload();
+  await page.waitForURL(`/albums/Hold%20Me%20In%20Your%20Arms/I'm%20never%20gonna%20give%20you%20up`);
+  const notModal = page.getByText("Not Modal", { exact: true });
+  await expect(notModal).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/og.test.ts b/examples/e2e/app-router/e2e/og.test.ts
new file mode 100644
index 00000000..c92b4d99
--- /dev/null
+++ b/examples/e2e/app-router/e2e/og.test.ts
@@ -0,0 +1,39 @@
+import { expect, test } from "@playwright/test";
+import { validateMd5 } from "../../utils";
+
+// This is the md5sums of the expected PNGs generated with `md5sum <file>`
+const OG_MD5 = "6e5e794ac0c27598a331690f96f05d00";
+const API_OG_MD5 = "cac95fc3e2d4d52870c0536bb18ba85b";
+
+test("Open-graph image to be in metatags and present", async ({ page, request }) => {
+  await page.goto("/og");
+
+  // Wait for meta tags to be present
+  const ogImageSrc = await page.locator('meta[property="og:image"]').getAttribute("content");
+  const ogImageAlt = await page.locator('meta[property="og:image:alt"]').getAttribute("content");
+  const ogImageType = await page.locator('meta[property="og:image:type"]').getAttribute("content");
+  const ogImageWidth = await page.locator('meta[property="og:image:width"]').getAttribute("content");
+  const ogImageHeight = await page.locator('meta[property="og:image:height"]').getAttribute("content");
+
+  // Verify meta tag exists and is the correct values
+  expect(ogImageSrc).not.toBe(null);
+  expect(ogImageAlt).toBe("OpenNext");
+  expect(ogImageType).toBe("image/png");
+  expect(ogImageWidth).toBe("1200");
+  expect(ogImageHeight).toBe("630");
+
+  // Check if the image source is working
+  const response = await request.get(`/og/${ogImageSrc?.split("/").at(-1)}`);
+  expect(response.status()).toBe(200);
+  expect(response.headers()["content-type"]).toBe("image/png");
+  expect(response.headers()["cache-control"]).toBe("public, immutable, no-transform, max-age=31536000");
+  expect(validateMd5(await response.body(), OG_MD5)).toBe(true);
+});
+
+test("next/og (vercel/og) to work in API route", async ({ request }) => {
+  const response = await request.get("api/og?title=opennext");
+  expect(response.status()).toBe(200);
+  expect(response.headers()["content-type"]).toBe("image/png");
+  expect(response.headers()["cache-control"]).toBe("public, immutable, no-transform, max-age=31536000");
+  expect(validateMd5(await response.body(), API_OG_MD5)).toBe(true);
+});
diff --git a/examples/e2e/app-router/e2e/parallel.test.ts b/examples/e2e/app-router/e2e/parallel.test.ts
new file mode 100644
index 00000000..bbf9c9d3
--- /dev/null
+++ b/examples/e2e/app-router/e2e/parallel.test.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "@playwright/test";
+
+test("Parallel routes", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "Parallel" }).click();
+
+  await page.waitForURL("/parallel");
+
+  // Neither are selected, so A/B shouldn't be rendered
+  let routeA = page.getByText("Parallel Route A");
+  let routeB = page.getByText("Parallel Route B");
+  await expect(routeA).not.toBeVisible();
+  await expect(routeB).not.toBeVisible();
+
+  // Enable A, which should be visible but not B
+  await page.locator('input[name="a"]').check();
+  routeA = page.getByText("Parallel Route A");
+  await expect(routeA).toBeVisible();
+  await expect(routeB).not.toBeVisible();
+
+  // Enable B, both should be visible
+  await page.locator('input[name="b"]').check();
+  routeB = page.getByText("Parallel Route B");
+  await expect(routeA).toBeVisible();
+  await expect(routeB).toBeVisible();
+
+  // Click on A, should go to a-page
+  await page.getByText("Go to a-page").click();
+  await page.waitForURL("/parallel/a-page");
+
+  // Should render contents of a-page
+  routeA = page.getByText("A Page");
+  await expect(routeA).toBeVisible();
+
+  // Click on B, should go to b-page
+  await page.getByText("Go to b-page").click();
+  await page.waitForURL("/parallel/b-page");
+
+  // Should render contents of b-page
+  routeB = page.getByText("B Page");
+  await expect(routeB).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/playwright.config.ts b/examples/e2e/app-router/e2e/playwright.config.ts
new file mode 100644
index 00000000..f6c067bb
--- /dev/null
+++ b/examples/e2e/app-router/e2e/playwright.config.ts
@@ -0,0 +1,54 @@
+import { defineConfig, devices } from "@playwright/test";
+import type nodeProcess from "node:process";
+
+declare const process: typeof nodeProcess;
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+  testDir: "./",
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Retry on CI only */
+  retries: process.env.CI ? 2 : 0,
+  /* Opt out of parallel tests on CI. */
+  workers: process.env.CI ? 1 : undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: "html",
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: "http://localhost:8790",
+
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: "on-first-retry",
+  },
+
+  /* Configure projects for major browsers */
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+    // TODO(vicb): enable all browsers
+    // {
+    //   name: "firefox",
+    //   use: { ...devices["Desktop Firefox"] },
+    // },
+    // {
+    //   name: "webkit",
+    //   use: { ...devices["Desktop Safari"] },
+    // },
+  ],
+
+  /* Run your local dev server before starting the tests */
+  webServer: {
+    command: "pnpm preview",
+    url: "http://localhost:8790",
+    reuseExistingServer: !process.env.CI,
+    timeout: 70_000,
+  },
+});
diff --git a/examples/e2e/app-router/e2e/query.test.ts b/examples/e2e/app-router/e2e/query.test.ts
new file mode 100644
index 00000000..18153168
--- /dev/null
+++ b/examples/e2e/app-router/e2e/query.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * Tests that query params are available in middleware and RSC
+ */
+test("SearchQuery", async ({ page }) => {
+  await page.goto("/search-query?searchParams=e2etest&multi=one&multi=two");
+
+  const propsEl = page.getByText("Search Params via Props: e2etest");
+  const mwEl = page.getByText("Search Params via Middleware: mw/e2etest");
+  const multiEl = page.getByText("Multi-value Params (key: multi): 2");
+  const multiOne = page.getByText("one");
+  const multiTwo = page.getByText("two");
+  await expect(propsEl).toBeVisible();
+  await expect(mwEl).toBeVisible();
+  await expect(multiEl).toBeVisible();
+  await expect(multiOne).toBeVisible();
+  await expect(multiTwo).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/revalidateTag.test.ts b/examples/e2e/app-router/e2e/revalidateTag.test.ts
new file mode 100644
index 00000000..1dea31dd
--- /dev/null
+++ b/examples/e2e/app-router/e2e/revalidateTag.test.ts
@@ -0,0 +1,94 @@
+import { expect, test } from "@playwright/test";
+
+test("Revalidate tag", async ({ page, request }) => {
+  test.setTimeout(45000);
+  // We need to hit the page twice to make sure it's properly cached
+  // Turbo might cache next build result, resulting in the tag being newer than the page
+  // This can lead to the cache thinking that revalidate tag has been called when it hasn't
+  // This is because S3 cache files are not uploaded if they have the same BuildId
+  let responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/revalidate-tag");
+  await responsePromise;
+
+  responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/revalidate-tag");
+  let elLayout = page.getByText("Fetched time:");
+  const time = await elLayout.textContent();
+  let newTime: typeof time;
+
+  let response = await responsePromise;
+  const headers = response.headers();
+  const nextCacheHeader = headers["x-nextjs-cache"] ?? headers["x-opennext-cache"];
+  expect(nextCacheHeader).toMatch(/^(HIT|STALE)$/);
+
+  // Send revalidate tag request
+
+  const result = await request.get("/api/revalidate-tag");
+  expect(result.status()).toEqual(200);
+  const text = await result.text();
+  expect(text).toEqual("ok");
+
+  responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.reload();
+  elLayout = page.getByText("Fetched time:");
+  newTime = await elLayout.textContent();
+
+  expect(newTime).not.toEqual(time);
+
+  response = await responsePromise;
+  expect(response.headers()["x-nextjs-cache"]).toEqual("MISS");
+
+  //Check if nested page is also a miss
+  responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/revalidate-tag/nested");
+  elLayout = page.getByText("Fetched time:");
+  newTime = await elLayout.textContent();
+  expect(newTime).not.toEqual(time);
+
+  response = await responsePromise;
+  expect(response.headers()["x-nextjs-cache"]).toEqual("MISS");
+
+  // If we hit the page again, it should be a hit
+  responsePromise = page.waitForResponse((response) => {
+    return response.status() === 200;
+  });
+  await page.goto("/revalidate-tag/nested");
+
+  response = await responsePromise;
+  const headersNested = response.headers();
+  const nextCacheHeaderNested = headersNested["x-nextjs-cache"] ?? headersNested["x-opennext-cache"];
+  expect(nextCacheHeaderNested).toEqual("HIT");
+});
+
+test("Revalidate path", async ({ page, request }) => {
+  await page.goto("/revalidate-path");
+
+  let elLayout = page.getByText("RequestID:");
+  const initialReqId = await elLayout.textContent();
+
+  elLayout = page.getByText("Date:");
+  const initialDate = await elLayout.textContent();
+
+  // Send revalidate path request
+  const result = await request.get("/api/revalidate-path");
+  expect(result.status()).toEqual(200);
+  const text = await result.text();
+  expect(text).toEqual("ok");
+
+  await page.goto("/revalidate-path");
+  elLayout = page.getByText("RequestID:");
+  const newReqId = await elLayout.textContent();
+  expect(newReqId).not.toEqual(initialReqId);
+
+  elLayout = page.getByText("Date:");
+  const newDate = await elLayout.textContent();
+  expect(newDate).not.toEqual(initialDate);
+});
diff --git a/examples/e2e/app-router/e2e/serverActions.test.ts b/examples/e2e/app-router/e2e/serverActions.test.ts
new file mode 100644
index 00000000..411400b0
--- /dev/null
+++ b/examples/e2e/app-router/e2e/serverActions.test.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "@playwright/test";
+
+test("Server Actions", async ({ page }) => {
+  await page.goto("/");
+  await page.getByRole("link", { name: "Server Actions" }).click();
+
+  await page.waitForURL("/server-actions");
+  let el = page.getByText("Song: I'm never gonna give you up");
+  await expect(el).not.toBeVisible();
+
+  await page.getByRole("button", { name: "Fire Server Actions" }).click();
+  el = page.getByText("Song: I'm never gonna give you up");
+  await expect(el).toBeVisible();
+
+  // Reload page
+  await page.reload();
+  el = page.getByText("Song: I'm never gonna give you up");
+  await expect(el).not.toBeVisible();
+  await page.getByRole("button", { name: "Fire Server Actions" }).click();
+  el = page.getByText("Song: I'm never gonna give you up");
+  await expect(el).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/sse.test.ts b/examples/e2e/app-router/e2e/sse.test.ts
new file mode 100644
index 00000000..c167e131
--- /dev/null
+++ b/examples/e2e/app-router/e2e/sse.test.ts
@@ -0,0 +1,45 @@
+// NOTE: loading.tsx is currently broken on open - next
+//  This works locally but not on deployed apps
+
+import { expect, test } from "@playwright/test";
+
+// NOTE: We don't await page load b/c we want to see the Loading page
+test("Server Sent Events", async ({ page }) => {
+  await page.goto("/");
+  await page.locator('[href="/sse"]').click();
+  await page.waitForURL("/sse");
+
+  const msg0 = page.getByText(`Message 0: {"message":"open"`);
+  await expect(msg0).toBeVisible();
+
+  // 2nd message shouldn't arrive yet
+  let msg2 = page.getByText(`Message 2: {"message":"hello:2"`);
+  await expect(msg2).not.toBeVisible();
+  await page.waitForTimeout(2000);
+  // 2nd message should arrive after 2s
+  msg2 = page.getByText(`Message 2: {"message":"hello:2"`);
+  await expect(msg2).toBeVisible();
+
+  // 3rd message shouldn't arrive yet
+  let msg3 = page.getByText(`Message 3: {"message":"hello:3"`);
+  await expect(msg3).not.toBeVisible();
+  await page.waitForTimeout(2000);
+  // 3rd message should arrive after 2s
+  msg3 = page.getByText(`Message 3: {"message":"hello:3"`);
+  await expect(msg3).toBeVisible();
+
+  // 4th message shouldn't arrive yet
+  let msg4 = page.getByText(`Message 4: {"message":"hello:4"`);
+  await expect(msg4).not.toBeVisible();
+  await page.waitForTimeout(2000);
+  // 4th message should arrive after 2s
+  msg4 = page.getByText(`Message 4: {"message":"hello:4"`);
+  await expect(msg4).toBeVisible();
+
+  let close = page.getByText(`Message 5: {"message":"close"`);
+  await expect(close).not.toBeVisible();
+
+  await page.waitForTimeout(2000);
+  close = page.getByText(`Message 5: {"message":"close"`);
+  await expect(close).toBeVisible();
+});
diff --git a/examples/e2e/app-router/e2e/ssr.test.ts b/examples/e2e/app-router/e2e/ssr.test.ts
new file mode 100644
index 00000000..efc45e71
--- /dev/null
+++ b/examples/e2e/app-router/e2e/ssr.test.ts
@@ -0,0 +1,37 @@
+// NOTE: loading.tsx is currently broken on open - next
+//  This works locally but not on deployed apps
+
+import { expect, test } from "@playwright/test";
+
+// NOTE: We don't await page load b/c we want to see the Loading page
+test("Server Side Render and loading.tsx", async ({ page }) => {
+  test.setTimeout(600000);
+  await page.goto("/");
+  await page.getByRole("link", { name: "SSR" }).click();
+  await page.waitForURL("/ssr");
+
+  let loading: any;
+  let lastTime = "";
+
+  for (let i = 0; i < 5; i++) {
+    void page.reload();
+
+    loading = page.getByText("Loading...");
+    await expect(loading).toBeVisible();
+    const el = page.getByText("Time:");
+    await expect(el).toBeVisible();
+    const time = await el.textContent();
+    expect(time).not.toEqual(lastTime);
+    lastTime = time!;
+    await page.waitForTimeout(1000);
+  }
+});
+
+test("Fetch cache properly cached", async ({ page }) => {
+  await page.goto("/ssr");
+  const originalDate = await page.getByText("Cached fetch:").textContent();
+  await page.waitForTimeout(2000);
+  await page.reload();
+  const newDate = await page.getByText("Cached fetch:").textContent();
+  expect(originalDate).toEqual(newDate);
+});
diff --git a/examples/e2e/app-router/e2e/trailing.test.ts b/examples/e2e/app-router/e2e/trailing.test.ts
new file mode 100644
index 00000000..4287f0b0
--- /dev/null
+++ b/examples/e2e/app-router/e2e/trailing.test.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/test";
+
+test("trailingSlash redirect", async ({ page }) => {
+  const response = await page.goto("/ssr/");
+
+  expect(response?.request().redirectedFrom()?.url()).toMatch(/\/ssr\/$/);
+  expect(response?.request().url()).toMatch(/\/ssr$/);
+});
+
+test("trailingSlash redirect with search parameters", async ({ page }) => {
+  const response = await page.goto("/ssr/?happy=true");
+
+  expect(response?.request().redirectedFrom()?.url()).toMatch(/\/ssr\/\?happy=true$/);
+  expect(response?.request().url()).toMatch(/\/ssr\?happy=true$/);
+});
+
+test("trailingSlash redirect to external domain", async ({ page, baseURL }) => {
+  const response = await page.goto(`${baseURL}//sst.dev/`);
+  expect(response?.status()).toBe(404);
+});
diff --git a/examples/e2e/app-router/package.json b/examples/e2e/app-router/package.json
index da8c5a76..8189a7ec 100644
--- a/examples/e2e/app-router/package.json
+++ b/examples/e2e/app-router/package.json
@@ -10,8 +10,9 @@
     "lint": "next lint",
     "clean": "rm -rf .turbo node_modules .next .open-next",
     "build:worker": "pnpm opennextjs-cloudflare",
-    "dev:worker": "wrangler dev --port 8770 --inspector-port 9330",
-    "preview": "pnpm build:worker && pnpm dev:worker"
+    "dev:worker": "wrangler dev --port 8790 --inspector-port 9350",
+    "preview": "pnpm build:worker && pnpm dev:worker",
+    "e2e-fix": "playwright test -c e2e/playwright.config.ts"
   },
   "dependencies": {
     "@opennextjs/cloudflare": "workspace:*",
@@ -21,6 +22,7 @@
     "react-dom": "catalog:e2e"
   },
   "devDependencies": {
+    "@playwright/test": "catalog:",
     "@types/node": "catalog:e2e",
     "@types/react": "catalog:e2e",
     "@types/react-dom": "catalog:e2e",
diff --git a/examples/e2e/app-router/tsconfig.json b/examples/e2e/app-router/tsconfig.json
index 9e539bbd..4116885e 100644
--- a/examples/e2e/app-router/tsconfig.json
+++ b/examples/e2e/app-router/tsconfig.json
@@ -24,6 +24,6 @@
       "@example/shared": ["../shared"]
     }
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../utils.ts"],
   "exclude": ["node_modules", "open-next.config.ts"]
 }
diff --git a/examples/e2e/utils.ts b/examples/e2e/utils.ts
new file mode 100644
index 00000000..04242c5f
--- /dev/null
+++ b/examples/e2e/utils.ts
@@ -0,0 +1,5 @@
+import { createHash } from "node:crypto";
+
+export function validateMd5(data: Buffer, expectedHash: string) {
+  return createHash("md5").update(data).digest("hex") === expectedHash;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 556549f1..9a7af464 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -237,6 +237,9 @@ importers:
         specifier: catalog:e2e
         version: 19.0.0(react@19.0.0)
     devDependencies:
+      '@playwright/test':
+        specifier: 'catalog:'
+        version: 1.47.0
       '@types/node':
         specifier: catalog:e2e
         version: 20.17.6
@@ -6418,7 +6421,7 @@ snapshots:
       '@aws-sdk/client-sso-oidc': 3.726.0(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/client-sts': 3.726.1
       '@aws-sdk/core': 3.723.0
-      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)
+      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/middleware-bucket-endpoint': 3.726.0
       '@aws-sdk/middleware-expect-continue': 3.723.0
       '@aws-sdk/middleware-flexible-checksums': 3.723.0
@@ -6572,7 +6575,7 @@ snapshots:
       '@aws-crypto/sha256-js': 5.2.0
       '@aws-sdk/client-sts': 3.726.1
       '@aws-sdk/core': 3.723.0
-      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)
+      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/middleware-host-header': 3.723.0
       '@aws-sdk/middleware-logger': 3.723.0
       '@aws-sdk/middleware-recursion-detection': 3.723.0
@@ -6828,7 +6831,7 @@ snapshots:
       '@aws-crypto/sha256-js': 5.2.0
       '@aws-sdk/client-sso-oidc': 3.726.0(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/core': 3.723.0
-      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)
+      '@aws-sdk/credential-provider-node': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/middleware-host-header': 3.723.0
       '@aws-sdk/middleware-logger': 3.723.0
       '@aws-sdk/middleware-recursion-detection': 3.723.0
@@ -6978,14 +6981,14 @@ snapshots:
       - '@aws-sdk/client-sso-oidc'
       - aws-crt
 
-  '@aws-sdk/credential-provider-ini@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)':
+  '@aws-sdk/credential-provider-ini@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)':
     dependencies:
       '@aws-sdk/client-sts': 3.726.1
       '@aws-sdk/core': 3.723.0
       '@aws-sdk/credential-provider-env': 3.723.0
       '@aws-sdk/credential-provider-http': 3.723.0
       '@aws-sdk/credential-provider-process': 3.723.0
-      '@aws-sdk/credential-provider-sso': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))
+      '@aws-sdk/credential-provider-sso': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))
       '@aws-sdk/credential-provider-web-identity': 3.723.0(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/types': 3.723.0
       '@smithy/credential-provider-imds': 4.0.1
@@ -7032,13 +7035,13 @@ snapshots:
       - '@aws-sdk/client-sts'
       - aws-crt
 
-  '@aws-sdk/credential-provider-node@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)':
+  '@aws-sdk/credential-provider-node@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)':
     dependencies:
       '@aws-sdk/credential-provider-env': 3.723.0
       '@aws-sdk/credential-provider-http': 3.723.0
-      '@aws-sdk/credential-provider-ini': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.726.1)
+      '@aws-sdk/credential-provider-ini': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/credential-provider-process': 3.723.0
-      '@aws-sdk/credential-provider-sso': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))
+      '@aws-sdk/credential-provider-sso': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))
       '@aws-sdk/credential-provider-web-identity': 3.723.0(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/types': 3.723.0
       '@smithy/credential-provider-imds': 4.0.1
@@ -7103,11 +7106,11 @@ snapshots:
       - '@aws-sdk/client-sso-oidc'
       - aws-crt
 
-  '@aws-sdk/credential-provider-sso@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))':
+  '@aws-sdk/credential-provider-sso@3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))':
     dependencies:
       '@aws-sdk/client-sso': 3.726.0
       '@aws-sdk/core': 3.723.0
-      '@aws-sdk/token-providers': 3.723.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))
+      '@aws-sdk/token-providers': 3.723.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))
       '@aws-sdk/types': 3.723.0
       '@smithy/property-provider': 4.0.1
       '@smithy/shared-ini-file-loader': 4.0.1
@@ -7408,7 +7411,7 @@ snapshots:
       '@smithy/types': 3.7.1
       tslib: 2.8.1
 
-  '@aws-sdk/token-providers@3.723.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.699.0))':
+  '@aws-sdk/token-providers@3.723.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1))':
     dependencies:
       '@aws-sdk/client-sso-oidc': 3.726.0(@aws-sdk/client-sts@3.726.1)
       '@aws-sdk/types': 3.723.0