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