From 2e4d2a065e913da7ad24d7704160a49fe54d677b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 16 Nov 2022 17:42:45 +0000 Subject: [PATCH 01/50] chore: all the tests --- package-lock.json | 202 +--- test/e2e/basepath/external/page.html | 5 + test/e2e/basepath/pages/404.js | 5 + test/e2e/basepath/pages/[slug].js | 4 + test/e2e/basepath/pages/_app.js | 52 + .../basepath/pages/absolute-url-basepath.js | 25 + .../pages/absolute-url-no-basepath.js | 22 + test/e2e/basepath/pages/absolute-url.js | 16 + test/e2e/basepath/pages/amp-hybrid.js | 4 + .../e2e/basepath/pages/catchall/[...parts].js | 4 + test/e2e/basepath/pages/docs/another.js | 2 + test/e2e/basepath/pages/error-route.js | 8 + test/e2e/basepath/pages/external-and-back.js | 12 + test/e2e/basepath/pages/gsp.js | 18 + test/e2e/basepath/pages/gssp.js | 19 + test/e2e/basepath/pages/hello.js | 67 ++ test/e2e/basepath/pages/index.js | 28 + test/e2e/basepath/pages/index/index.js.bak | 22 + .../basepath/pages/invalid-manual-basepath.js | 12 + test/e2e/basepath/pages/link-to-root.js | 11 + test/e2e/basepath/pages/other-page.js | 2 + test/e2e/basepath/pages/slow-route.js | 10 + test/e2e/basepath/pages/something-else.js | 3 + test/e2e/basepath/pages/ssr.js | 11 + test/e2e/basepath/public/data.txt | 1 + test/e2e/browserslist-extends/index.test.ts | 38 + test/e2e/browserslist/app/pages/index.js | 22 + test/e2e/browserslist/browserslist.test.ts | 43 + .../browserslist/legacybrowsers-false.test.ts | 42 + .../browserslist/legacybrowsers-true.test.ts | 47 + .../async-function.test.ts | 33 + .../e2e/config-promise-export/promise.test.ts | 33 + .../dynamic-route-interpolation/index.test.ts | 91 ++ .../app/pages/api/edge.js | 11 + .../app/pages/api/index.js | 11 + .../index.test.ts | 53 + .../edge-async-local-storage/index.test.ts | 126 +++ .../edge-can-read-request-body/app/.gitignore | 1 + .../app/middleware.js | 49 + .../app/pages/api/nothing.js | 3 + .../edge-can-read-request-body/index.test.ts | 124 +++ test/e2e/edge-can-use-wasm-files/add.wasm | Bin 0 -> 126 bytes .../e2e/edge-can-use-wasm-files/index.test.ts | 150 +++ .../app/pages/api/edge.js | 54 + .../app/src/text-file.txt | 1 + .../app/src/vercel.png | Bin 0 -> 30079 bytes .../index.test.ts | 103 ++ .../index.test.ts | 58 ++ .../app/next.config.js | 14 + .../app/pages/[id].js | 23 + .../app/pages/index.js | 23 + .../index.test.ts | 129 +++ .../index.test.ts | 38 + test/e2e/i18n-api-support/index.test.ts | 74 ++ .../app/next.config.js | 6 + .../pages/gsp-blocking-redirect/[locale].js | 21 + .../pages/gsp-fallback-redirect/[locale].js | 21 + .../app/pages/gssp-redirect/[locale].js | 17 + .../app/pages/home.js | 12 + .../app/pages/index.js | 43 + .../i18n-data-fetching-redirect/index.test.ts | 145 +++ .../app/pages/newpage.js | 6 + .../redirects-with-basepath.test.ts | 91 ++ .../redirects.test.ts | 90 ++ .../rewrites-with-basepath.test.ts | 94 ++ .../rewrites.test.ts | 90 ++ .../app/next.config.js | 6 + .../app/pages/[dynamic].js | 3 + .../app/pages/static.js | 3 + .../with-i18n.test.ts | 94 ++ .../without-i18n.test.ts | 77 ++ .../link-with-api-rewrite/app/next.config.js | 17 + .../app/pages/api/json.js | 5 + .../link-with-api-rewrite/app/pages/index.js | 14 + test/e2e/link-with-api-rewrite/index.test.ts | 74 ++ .../app/next.config.js | 6 + .../app/pages/another.js | 50 + .../app/pages/dynamic/[slug].js | 58 ++ .../app/pages/index.js | 41 + .../e2e/manual-client-base-path/index.test.ts | 203 ++++ .../middleware-base-path/app/middleware.js | 24 + .../middleware-base-path/app/next.config.js | 3 + .../middleware-base-path/app/pages/about.js | 7 + .../app/pages/dynamic-routes/[routeName].js | 11 + .../middleware-base-path/app/pages/index.js | 41 + .../middleware-base-path/test/index.test.ts | 48 + .../app/middleware.js | 17 + .../app/next.config.js | 3 + .../app/pages/index.js | 14 + .../app/pages/routes.js | 11 + .../test/index.test.ts | 56 ++ .../app/middleware.js | 21 + .../app/next.config.js | 6 + .../app/pages/index.js | 14 + .../app/pages/routes.js | 26 + .../test/index.test.ts | 55 + .../app/middleware.js | 81 ++ .../app/pages/index.js | 14 + .../app/pages/routes.js | 16 + .../test/index.test.ts | 167 ++++ .../index.test.ts | 91 ++ .../index.test.ts | 279 ++++++ test/e2e/middleware-general/app/middleware.js | 273 +++++ .../e2e/middleware-general/app/next.config.js | 35 + test/e2e/middleware-general/app/pages/[id].js | 3 + test/e2e/middleware-general/app/pages/_app.js | 8 + .../middleware-general/app/pages/about/a.js | 7 + .../middleware-general/app/pages/about/b.js | 7 + .../app/pages/api/edge-search-params.js | 10 + .../app/pages/api/headers.js | 3 + .../app/pages/blog/[slug].js | 23 + .../app/pages/error-throw.js | 12 + .../e2e/middleware-general/app/pages/error.js | 11 + .../middleware-general/app/pages/shallow.js | 43 + .../app/pages/ssg/[slug].js | 42 + .../app/pages/ssr-page-2.js | 11 + .../middleware-general/app/pages/ssr-page.js | 11 + .../e2e/middleware-general/test/index.test.ts | 677 +++++++++++++ test/e2e/middleware-matcher/app/middleware.js | 17 + .../app/pages/another-middleware.js | 22 + .../app/pages/blog/[slug].js | 27 + .../e2e/middleware-matcher/app/pages/index.js | 26 + .../app/pages/with-middleware.js | 16 + test/e2e/middleware-matcher/index.test.ts | 530 ++++++++++ .../middleware-redirects/app/middleware.js | 83 ++ .../middleware-redirects/app/next.config.js | 15 + .../middleware-redirects/app/pages/_app.js | 8 + .../middleware-redirects/app/pages/api/ok.js | 3 + .../app/pages/dynamic/[slug].js | 13 + .../middleware-redirects/app/pages/index.js | 43 + .../app/pages/new-home.js | 7 + .../middleware-redirects/test/index.test.ts | 178 ++++ .../app/.gitignore | 1 + .../app/middleware.js | 30 + .../app/next.config.js | 1 + .../app/pages/api/dump-headers-edge.js | 11 + .../app/pages/api/dump-headers-serverless.js | 6 + .../app/pages/ssr-page.js | 15 + .../test/index.test.ts | 119 +++ .../middleware-responses/app/middleware.js | 74 ++ .../middleware-responses/app/next.config.js | 6 + .../middleware-responses/app/pages/index.js | 48 + .../middleware-responses/test/index.test.ts | 89 ++ .../e2e/middleware-rewrites/app/middleware.js | 135 +++ .../middleware-rewrites/app/next.config.js | 27 + test/e2e/middleware-rewrites/app/pages/404.js | 3 + .../middleware-rewrites/app/pages/[param].js | 12 + .../e2e/middleware-rewrites/app/pages/_app.js | 8 + .../app/pages/ab-test/a.js | 9 + .../app/pages/ab-test/b.js | 9 + .../app/pages/about-bypass.js | 12 + .../middleware-rewrites/app/pages/about.js | 16 + .../app/pages/clear-query-params.js | 12 + .../app/pages/country/[country].js | 22 + .../app/pages/dynamic-fallback/[...parts].js | 5 + .../app/pages/fallback-true-blog/[slug].js | 48 + .../e2e/middleware-rewrites/app/pages/i18n.js | 40 + .../middleware-rewrites/app/pages/index.js | 82 ++ test/e2e/middleware-rewrites/app/pages/ssg.js | 14 + .../middleware-rewrites/test/index.test.ts | 770 ++++++++++++++ .../app/middleware.js | 106 ++ .../app/next.config.js | 32 + .../app/pages/[id].js | 3 + .../app/pages/_app.js | 8 + .../app/pages/about/a.js | 7 + .../app/pages/about/b.js | 7 + .../app/pages/api/headers.js | 3 + .../app/pages/blog/[slug].js | 23 + .../app/pages/error-throw.js | 12 + .../app/pages/error.js | 11 + .../app/pages/shallow.js | 43 + .../app/pages/ssg/[slug].js | 42 + .../app/pages/ssr-page-2.js | 11 + .../app/pages/ssr-page.js | 11 + .../test/index.test.ts | 412 ++++++++ test/e2e/new-link-behavior/app/next.config.js | 1 + test/e2e/new-link-behavior/app/pages/about.js | 9 + .../app/pages/classname-pass-through.js | 8 + .../app/pages/id-pass-through.js | 8 + test/e2e/new-link-behavior/app/pages/index.js | 9 + .../app/pages/multiple-children.js | 11 + .../app/pages/onclick-prevent-default.js | 17 + .../new-link-behavior/app/pages/onclick.js | 13 + .../child-a-tag-error.test.ts | 42 + .../child-a-tag-error/next.config.js | 3 + .../child-a-tag-error/pages/about.js | 12 + .../child-a-tag-error/pages/index.js | 12 + test/e2e/new-link-behavior/index.test.ts | 93 ++ .../e2e/new-link-behavior/material-ui.test.ts | 46 + .../material-ui/next.config.js | 3 + .../material-ui/pages/_app.js | 27 + .../material-ui/pages/_document.js | 88 ++ .../material-ui/pages/about.js | 25 + .../material-ui/pages/index.js | 24 + .../material-ui/src/Copyright.js | 15 + .../new-link-behavior/material-ui/src/Link.js | 27 + .../material-ui/src/ProTip.js | 25 + .../material-ui/src/createEmotionCache.js | 7 + .../material-ui/src/theme.js | 19 + test/e2e/new-link-behavior/stitches.test.ts | 41 + .../stitches/components/StitchesLogo.jsx | 50 + .../new-link-behavior/stitches/next.config.js | 3 + .../stitches/pages/_document.jsx | 22 + .../stitches/pages/about.jsx | 48 + .../stitches/pages/index.jsx | 49 + .../stitches/stitches.config.js | 81 ++ test/e2e/new-link-behavior/typescript.test.ts | 42 + .../typescript/next.config.js | 3 + .../typescript/pages/index.tsx | 93 ++ .../typescript/pages/ref.tsx | 21 + .../typescript/tsconfig.json | 20 + .../next-font/app/components/CompWithFonts.js | 22 + test/e2e/next-font/app/fonts/my-font.woff2 | Bin 0 -> 17508 bytes .../next-font/app/fonts/my-other-font.woff2 | Bin 0 -> 19100 bytes .../app/fonts/roboto/roboto-100-italic.woff2 | Bin 0 -> 12440 bytes .../app/fonts/roboto/roboto-100.woff2 | Bin 0 -> 10972 bytes .../app/fonts/roboto/roboto-400-italic.woff2 | Bin 0 -> 12684 bytes .../app/fonts/roboto/roboto-400.woff2 | Bin 0 -> 11028 bytes .../app/fonts/roboto/roboto-900-italic.woff2 | Bin 0 -> 12672 bytes .../app/fonts/roboto/roboto-900.woff2 | Bin 0 -> 10992 bytes test/e2e/next-font/app/next.config.js | 13 + test/e2e/next-font/app/pages/_app.js | 16 + test/e2e/next-font/app/pages/variables.js | 67 ++ test/e2e/next-font/app/pages/with-fallback.js | 25 + test/e2e/next-font/app/pages/with-fonts.js | 14 + .../next-font/app/pages/with-google-fonts.js | 40 + .../next-font/app/pages/with-local-fonts.js | 153 +++ test/e2e/next-font/app/pages/without-fonts.js | 3 + test/e2e/next-font/basepath.test.ts | 54 + test/e2e/next-font/basepath/next.config.js | 10 + test/e2e/next-font/basepath/pages/index.js | 6 + .../font-loader-in-document-error.test.ts | 36 + .../pages/_document.js | 17 + .../font-loader-in-document/pages/index.js | 3 + ...b1603gg7S2nfgRYIctxuTB_7Tp05GNyXkb24.woff2 | Bin 0 -> 15616 bytes ...b1603gg7S2nfgRYIctxuTBv7Tp05GNyXkb24.woff2 | Bin 0 -> 5532 bytes ...8nib1603gg7S2nfgRYIctxuTCf7Tp05GNyXk.woff2 | Bin 0 -> 17508 bytes .../m8JVjfNVeKWVnh3QMuKkFcZVaUuH99GUDg.woff2 | Bin 0 -> 19100 bytes test/e2e/next-font/google-fetch-error.test.ts | 76 ++ .../google-fetch-error/next.config.js | 9 + .../google-fetch-error/pages/index.js | 10 + .../next-font/google-font-mocked-responses.js | 941 ++++++++++++++++++ test/e2e/next-font/index.test.ts | 552 ++++++++++ .../with-font-declarations-file.test.ts | 145 +++ .../components/roboto-comp.js | 10 + .../with-font-declarations-file/fonts.js | 21 + .../with-font-declarations-file/my-font.woff2 | 1 + .../next.config.js | 10 + .../with-font-declarations-file/pages/_app.js | 15 + .../pages/inter.js | 10 + .../pages/local-font.js | 5 + .../pages/roboto.js | 5 + .../next-font/without-preloaded-fonts.test.ts | 132 +++ .../without-preloaded-fonts/next.config.js | 10 + .../without-preloaded-fonts/pages/_app.js | 12 + .../pages/no-preload.js | 6 + .../pages/without-fonts.js | 3 + test/e2e/next-head/app/components/meta.js | 15 + test/e2e/next-head/app/pages/_document.js | 25 + test/e2e/next-head/app/pages/index.js | 15 + test/e2e/next-head/index.test.ts | 70 ++ test/e2e/next-script/index.test.ts | 423 ++++++++ .../index.test.ts | 87 ++ .../e2e/nonce-head-manager/app/next.config.js | 15 + .../nonce-head-manager/app/pages/_document.js | 17 + test/e2e/nonce-head-manager/app/pages/csp.js | 24 + .../e2e/nonce-head-manager/app/pages/index.js | 24 + .../nonce-head-manager/app/public/src-1.js | 2 + .../nonce-head-manager/app/public/src-2.js | 2 + test/e2e/nonce-head-manager/index.test.ts | 56 ++ test/e2e/og-api/app/next.config.js | 4 + test/e2e/og-api/app/pages/api/og.js | 26 + test/e2e/og-api/app/pages/index.js | 3 + test/e2e/og-api/index.test.ts | 30 + test/e2e/postcss-config-cjs/app/pages/_app.js | 7 + .../e2e/postcss-config-cjs/app/pages/index.js | 63 ++ .../postcss-config-cjs/app/postcss.config.cjs | 7 + .../app/tailwind.config.cjs | 12 + test/e2e/postcss-config-cjs/index.test.ts | 44 + test/e2e/prerender-native-module/data.sqlite | Bin 0 -> 8192 bytes .../pages/blog/[slug].js | 46 + .../prerender-native-module/pages/index.js | 16 + test/e2e/prerender/pages/another/index.js | 30 + .../e2e/prerender/pages/api-docs/[...slug].js | 27 + test/e2e/prerender/pages/api/bad.js | 3 + test/e2e/prerender/pages/api/enable.js | 4 + .../prerender/pages/api/manual-revalidate.js | 19 + test/e2e/prerender/pages/bad-gssp.js | 7 + test/e2e/prerender/pages/bad-ssr.js | 7 + .../pages/blocking-fallback-once/[slug].js | 55 + .../pages/blocking-fallback-some/[slug].js | 43 + .../pages/blocking-fallback/[slug].js | 72 ++ .../prerender/pages/blog/[post]/[comment].js | 41 + test/e2e/prerender/pages/blog/[post]/index.js | 61 ++ test/e2e/prerender/pages/blog/index.js | 24 + .../pages/catchall-explicit/[...slug].js | 43 + .../pages/catchall-optional/[[...slug]].js | 29 + .../e2e/prerender/pages/catchall/[...slug].js | 34 + .../e2e/prerender/pages/default-revalidate.js | 24 + test/e2e/prerender/pages/dynamic/[slug].js | 27 + .../prerender/pages/fallback-only/[slug].js | 43 + test/e2e/prerender/pages/index.js | 108 ++ test/e2e/prerender/pages/index/index.js.bak | 18 + test/e2e/prerender/pages/lang/[lang]/about.js | 12 + test/e2e/prerender/pages/large-page-data.js | 26 + .../prerender/pages/non-json-blocking/[p].js | 21 + test/e2e/prerender/pages/non-json/[p].js | 21 + test/e2e/prerender/pages/normal.js | 1 + test/e2e/prerender/pages/preview.js | 17 + test/e2e/prerender/pages/something.js | 34 + .../prerender/pages/user/[user]/profile.js | 28 + test/e2e/prerender/world.txt | 1 + .../app/middleware.js | 5 + .../app/pages/api/index.js | 28 + .../app/pages/api/post.js | 12 + .../test/index.test.ts | 53 + .../index.test.ts | 176 ++++ .../next.config.js | 5 + .../pages/[id].js | 49 + .../app/middleware.js | 49 + .../app/next.config.js | 27 + .../app/pages/another.js | 13 + .../app/pages/api/test-cookie-edge.js | 12 + .../app/pages/api/test-cookie.js | 5 + .../app/pages/blog/[slug].js | 13 + .../app/pages/index.js | 17 + .../index.test.ts | 212 ++++ test/e2e/ssr-react-context/app/context.js | 5 + test/e2e/ssr-react-context/app/pages/_app.js | 9 + .../ssr-react-context/app/pages/consumer.js | 17 + test/e2e/ssr-react-context/app/pages/index.js | 16 + test/e2e/ssr-react-context/index.test.ts | 46 + .../custom-server/next.config.js | 1 + .../custom-server/pages/index.js | 7 + .../e2e/streaming-ssr/custom-server/server.js | 43 + test/e2e/streaming-ssr/index.test.ts | 190 ++++ .../streaming-ssr/next.config.js | 3 + .../streaming-ssr/pages/api/user/[id].js | 7 + .../streaming-ssr/pages/api/user/login.js | 3 + .../streaming-ssr/pages/hello.js | 12 + .../streaming-ssr/pages/index.js | 14 + .../streaming-ssr/pages/multi-byte.js | 9 + .../streaming-ssr/pages/router.js | 11 + test/e2e/styled-jsx/app/.npmrc | 2 + .../app/node_modules_bak/my-comps/button.js | 31 + test/e2e/styled-jsx/app/pages/amp.js | 12 + test/e2e/styled-jsx/app/pages/index.js | 11 + test/e2e/styled-jsx/index.test.ts | 68 ++ test/e2e/swc-warnings/index.test.ts | 68 ++ .../app/edge-rsc/page.server.js.bak | 18 + test/e2e/switchable-runtime/app/layout.js | 14 + .../app/legacy-extension/page.server.js | 3 + .../app/node-rsc-isr/page.js | 22 + .../app/node-rsc-ssg/page.js | 24 + .../app/node-rsc-ssr/page.js | 26 + .../switchable-runtime/app/node-rsc/page.js | 20 + test/e2e/switchable-runtime/index.test.ts | 728 ++++++++++++++ test/e2e/switchable-runtime/next.config.js | 21 + test/e2e/switchable-runtime/pages/api/edge.js | 7 + .../e2e/switchable-runtime/pages/api/hello.js | 7 + test/e2e/switchable-runtime/pages/api/node.js | 3 + .../pages/api/switch-in-dev-same-content.js | 3 + .../pages/api/switch-in-dev.js | 3 + .../pages/api/syntax-error-in-dev.js | 5 + test/e2e/switchable-runtime/pages/edge.js | 18 + .../e2e/switchable-runtime/pages/edge/[id].js | 13 + test/e2e/switchable-runtime/pages/edge/foo.js | 13 + .../pages/invalid-runtime.js | 3 + test/e2e/switchable-runtime/pages/node-ssg.js | 26 + test/e2e/switchable-runtime/pages/node-ssr.js | 26 + test/e2e/switchable-runtime/pages/node.js | 44 + test/e2e/switchable-runtime/pages/static.js | 14 + .../switchable-runtime/pages/switch-in-dev.js | 7 + test/e2e/switchable-runtime/utils/runtime.js | 14 + test/e2e/switchable-runtime/utils/time.js | 12 + test/e2e/transpile-packages/index.test.ts | 85 ++ .../e2e/transpile-packages/npm/next.config.js | 5 + .../npm/node_modules_bak/css/global-css.js | 1 + .../npm/node_modules_bak/css/global-scss.js | 1 + .../npm/node_modules_bak/css/global.css | 3 + .../npm/node_modules_bak/css/global.scss | 5 + .../npm/node_modules_bak/css/module-css.js | 2 + .../npm/node_modules_bak/css/module-scss.js | 2 + .../npm/node_modules_bak/css/package.json | 3 + .../npm/node_modules_bak/css/style.module.css | 3 + .../node_modules_bak/css/style.module.scss | 5 + .../npm/pages/css-modules.js | 5 + .../npm/pages/global-css.js | 5 + .../npm/pages/global-scss.js | 5 + .../npm/pages/scss-modules.js | 5 + test/e2e/type-module-interop/index.test.ts | 113 +++ test/e2e/undici-fetch/index.test.ts | 130 +++ test/e2e/yarn-pnp/test/pwa-example.test.ts | 5 + test/e2e/yarn-pnp/test/utils.ts | 65 ++ test/e2e/yarn-pnp/test/with-eslint.test.ts | 5 + test/e2e/yarn-pnp/test/with-mdx.test.ts | 5 + test/e2e/yarn-pnp/test/with-next-sass.test.ts | 5 + 397 files changed, 16790 insertions(+), 180 deletions(-) create mode 100644 test/e2e/basepath/external/page.html create mode 100644 test/e2e/basepath/pages/404.js create mode 100644 test/e2e/basepath/pages/[slug].js create mode 100644 test/e2e/basepath/pages/_app.js create mode 100644 test/e2e/basepath/pages/absolute-url-basepath.js create mode 100644 test/e2e/basepath/pages/absolute-url-no-basepath.js create mode 100644 test/e2e/basepath/pages/absolute-url.js create mode 100644 test/e2e/basepath/pages/amp-hybrid.js create mode 100644 test/e2e/basepath/pages/catchall/[...parts].js create mode 100644 test/e2e/basepath/pages/docs/another.js create mode 100644 test/e2e/basepath/pages/error-route.js create mode 100644 test/e2e/basepath/pages/external-and-back.js create mode 100644 test/e2e/basepath/pages/gsp.js create mode 100644 test/e2e/basepath/pages/gssp.js create mode 100644 test/e2e/basepath/pages/hello.js create mode 100644 test/e2e/basepath/pages/index.js create mode 100644 test/e2e/basepath/pages/index/index.js.bak create mode 100644 test/e2e/basepath/pages/invalid-manual-basepath.js create mode 100644 test/e2e/basepath/pages/link-to-root.js create mode 100644 test/e2e/basepath/pages/other-page.js create mode 100644 test/e2e/basepath/pages/slow-route.js create mode 100644 test/e2e/basepath/pages/something-else.js create mode 100644 test/e2e/basepath/pages/ssr.js create mode 100644 test/e2e/basepath/public/data.txt create mode 100644 test/e2e/browserslist-extends/index.test.ts create mode 100644 test/e2e/browserslist/app/pages/index.js create mode 100644 test/e2e/browserslist/browserslist.test.ts create mode 100644 test/e2e/browserslist/legacybrowsers-false.test.ts create mode 100644 test/e2e/browserslist/legacybrowsers-true.test.ts create mode 100644 test/e2e/config-promise-export/async-function.test.ts create mode 100644 test/e2e/config-promise-export/promise.test.ts create mode 100644 test/e2e/dynamic-route-interpolation/index.test.ts create mode 100644 test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/edge.js create mode 100644 test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/index.js create mode 100644 test/e2e/edge-api-endpoints-can-receive-body/index.test.ts create mode 100644 test/e2e/edge-async-local-storage/index.test.ts create mode 100644 test/e2e/edge-can-read-request-body/app/.gitignore create mode 100644 test/e2e/edge-can-read-request-body/app/middleware.js create mode 100644 test/e2e/edge-can-read-request-body/app/pages/api/nothing.js create mode 100644 test/e2e/edge-can-read-request-body/index.test.ts create mode 100644 test/e2e/edge-can-use-wasm-files/add.wasm create mode 100644 test/e2e/edge-can-use-wasm-files/index.test.ts create mode 100644 test/e2e/edge-compiler-can-import-blob-assets/app/pages/api/edge.js create mode 100644 test/e2e/edge-compiler-can-import-blob-assets/app/src/text-file.txt create mode 100644 test/e2e/edge-compiler-can-import-blob-assets/app/src/vercel.png create mode 100644 test/e2e/edge-compiler-can-import-blob-assets/index.test.ts create mode 100644 test/e2e/edge-compiler-module-exports-preference/index.test.ts create mode 100644 test/e2e/edge-render-getserversideprops/app/next.config.js create mode 100644 test/e2e/edge-render-getserversideprops/app/pages/[id].js create mode 100644 test/e2e/edge-render-getserversideprops/app/pages/index.js create mode 100644 test/e2e/edge-render-getserversideprops/index.test.ts create mode 100644 test/e2e/handle-non-hoisted-swc-helpers/index.test.ts create mode 100644 test/e2e/i18n-api-support/index.test.ts create mode 100644 test/e2e/i18n-data-fetching-redirect/app/next.config.js create mode 100644 test/e2e/i18n-data-fetching-redirect/app/pages/gsp-blocking-redirect/[locale].js create mode 100644 test/e2e/i18n-data-fetching-redirect/app/pages/gsp-fallback-redirect/[locale].js create mode 100644 test/e2e/i18n-data-fetching-redirect/app/pages/gssp-redirect/[locale].js create mode 100644 test/e2e/i18n-data-fetching-redirect/app/pages/home.js create mode 100644 test/e2e/i18n-data-fetching-redirect/app/pages/index.js create mode 100644 test/e2e/i18n-data-fetching-redirect/index.test.ts create mode 100644 test/e2e/i18n-ignore-redirect-source-locale/app/pages/newpage.js create mode 100644 test/e2e/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts create mode 100644 test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts create mode 100644 test/e2e/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts create mode 100644 test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts create mode 100644 test/e2e/ignore-invalid-popstateevent/app/next.config.js create mode 100644 test/e2e/ignore-invalid-popstateevent/app/pages/[dynamic].js create mode 100644 test/e2e/ignore-invalid-popstateevent/app/pages/static.js create mode 100644 test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts create mode 100644 test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts create mode 100644 test/e2e/link-with-api-rewrite/app/next.config.js create mode 100644 test/e2e/link-with-api-rewrite/app/pages/api/json.js create mode 100644 test/e2e/link-with-api-rewrite/app/pages/index.js create mode 100644 test/e2e/link-with-api-rewrite/index.test.ts create mode 100644 test/e2e/manual-client-base-path/app/next.config.js create mode 100644 test/e2e/manual-client-base-path/app/pages/another.js create mode 100644 test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js create mode 100644 test/e2e/manual-client-base-path/app/pages/index.js create mode 100644 test/e2e/manual-client-base-path/index.test.ts create mode 100644 test/e2e/middleware-base-path/app/middleware.js create mode 100644 test/e2e/middleware-base-path/app/next.config.js create mode 100644 test/e2e/middleware-base-path/app/pages/about.js create mode 100644 test/e2e/middleware-base-path/app/pages/dynamic-routes/[routeName].js create mode 100644 test/e2e/middleware-base-path/app/pages/index.js create mode 100644 test/e2e/middleware-base-path/test/index.test.ts create mode 100644 test/e2e/middleware-custom-matchers-basepath/app/middleware.js create mode 100644 test/e2e/middleware-custom-matchers-basepath/app/next.config.js create mode 100644 test/e2e/middleware-custom-matchers-basepath/app/pages/index.js create mode 100644 test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js create mode 100644 test/e2e/middleware-custom-matchers-basepath/test/index.test.ts create mode 100644 test/e2e/middleware-custom-matchers-i18n/app/middleware.js create mode 100644 test/e2e/middleware-custom-matchers-i18n/app/next.config.js create mode 100644 test/e2e/middleware-custom-matchers-i18n/app/pages/index.js create mode 100644 test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js create mode 100644 test/e2e/middleware-custom-matchers-i18n/test/index.test.ts create mode 100644 test/e2e/middleware-custom-matchers/app/middleware.js create mode 100644 test/e2e/middleware-custom-matchers/app/pages/index.js create mode 100644 test/e2e/middleware-custom-matchers/app/pages/routes.js create mode 100644 test/e2e/middleware-custom-matchers/test/index.test.ts create mode 100644 test/e2e/middleware-fetches-with-any-http-method/index.test.ts create mode 100644 test/e2e/middleware-fetches-with-body/index.test.ts create mode 100644 test/e2e/middleware-general/app/middleware.js create mode 100644 test/e2e/middleware-general/app/next.config.js create mode 100644 test/e2e/middleware-general/app/pages/[id].js create mode 100644 test/e2e/middleware-general/app/pages/_app.js create mode 100644 test/e2e/middleware-general/app/pages/about/a.js create mode 100644 test/e2e/middleware-general/app/pages/about/b.js create mode 100644 test/e2e/middleware-general/app/pages/api/edge-search-params.js create mode 100644 test/e2e/middleware-general/app/pages/api/headers.js create mode 100644 test/e2e/middleware-general/app/pages/blog/[slug].js create mode 100644 test/e2e/middleware-general/app/pages/error-throw.js create mode 100644 test/e2e/middleware-general/app/pages/error.js create mode 100644 test/e2e/middleware-general/app/pages/shallow.js create mode 100644 test/e2e/middleware-general/app/pages/ssg/[slug].js create mode 100644 test/e2e/middleware-general/app/pages/ssr-page-2.js create mode 100644 test/e2e/middleware-general/app/pages/ssr-page.js create mode 100644 test/e2e/middleware-general/test/index.test.ts create mode 100644 test/e2e/middleware-matcher/app/middleware.js create mode 100644 test/e2e/middleware-matcher/app/pages/another-middleware.js create mode 100644 test/e2e/middleware-matcher/app/pages/blog/[slug].js create mode 100644 test/e2e/middleware-matcher/app/pages/index.js create mode 100644 test/e2e/middleware-matcher/app/pages/with-middleware.js create mode 100644 test/e2e/middleware-matcher/index.test.ts create mode 100644 test/e2e/middleware-redirects/app/middleware.js create mode 100644 test/e2e/middleware-redirects/app/next.config.js create mode 100644 test/e2e/middleware-redirects/app/pages/_app.js create mode 100644 test/e2e/middleware-redirects/app/pages/api/ok.js create mode 100644 test/e2e/middleware-redirects/app/pages/dynamic/[slug].js create mode 100644 test/e2e/middleware-redirects/app/pages/index.js create mode 100644 test/e2e/middleware-redirects/app/pages/new-home.js create mode 100644 test/e2e/middleware-redirects/test/index.test.ts create mode 100644 test/e2e/middleware-request-header-overrides/app/.gitignore create mode 100644 test/e2e/middleware-request-header-overrides/app/middleware.js create mode 100644 test/e2e/middleware-request-header-overrides/app/next.config.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js create mode 100644 test/e2e/middleware-request-header-overrides/test/index.test.ts create mode 100644 test/e2e/middleware-responses/app/middleware.js create mode 100644 test/e2e/middleware-responses/app/next.config.js create mode 100644 test/e2e/middleware-responses/app/pages/index.js create mode 100644 test/e2e/middleware-responses/test/index.test.ts create mode 100644 test/e2e/middleware-rewrites/app/middleware.js create mode 100644 test/e2e/middleware-rewrites/app/next.config.js create mode 100644 test/e2e/middleware-rewrites/app/pages/404.js create mode 100644 test/e2e/middleware-rewrites/app/pages/[param].js create mode 100644 test/e2e/middleware-rewrites/app/pages/_app.js create mode 100644 test/e2e/middleware-rewrites/app/pages/ab-test/a.js create mode 100644 test/e2e/middleware-rewrites/app/pages/ab-test/b.js create mode 100644 test/e2e/middleware-rewrites/app/pages/about-bypass.js create mode 100644 test/e2e/middleware-rewrites/app/pages/about.js create mode 100644 test/e2e/middleware-rewrites/app/pages/clear-query-params.js create mode 100644 test/e2e/middleware-rewrites/app/pages/country/[country].js create mode 100644 test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js create mode 100644 test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js create mode 100644 test/e2e/middleware-rewrites/app/pages/i18n.js create mode 100644 test/e2e/middleware-rewrites/app/pages/index.js create mode 100644 test/e2e/middleware-rewrites/app/pages/ssg.js create mode 100644 test/e2e/middleware-rewrites/test/index.test.ts create mode 100644 test/e2e/middleware-trailing-slash/app/middleware.js create mode 100644 test/e2e/middleware-trailing-slash/app/next.config.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/[id].js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/_app.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/about/a.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/about/b.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/api/headers.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/blog/[slug].js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/error-throw.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/error.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/shallow.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/ssg/[slug].js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/ssr-page-2.js create mode 100644 test/e2e/middleware-trailing-slash/app/pages/ssr-page.js create mode 100644 test/e2e/middleware-trailing-slash/test/index.test.ts create mode 100644 test/e2e/new-link-behavior/app/next.config.js create mode 100644 test/e2e/new-link-behavior/app/pages/about.js create mode 100644 test/e2e/new-link-behavior/app/pages/classname-pass-through.js create mode 100644 test/e2e/new-link-behavior/app/pages/id-pass-through.js create mode 100644 test/e2e/new-link-behavior/app/pages/index.js create mode 100644 test/e2e/new-link-behavior/app/pages/multiple-children.js create mode 100644 test/e2e/new-link-behavior/app/pages/onclick-prevent-default.js create mode 100644 test/e2e/new-link-behavior/app/pages/onclick.js create mode 100644 test/e2e/new-link-behavior/child-a-tag-error.test.ts create mode 100644 test/e2e/new-link-behavior/child-a-tag-error/next.config.js create mode 100644 test/e2e/new-link-behavior/child-a-tag-error/pages/about.js create mode 100644 test/e2e/new-link-behavior/child-a-tag-error/pages/index.js create mode 100644 test/e2e/new-link-behavior/index.test.ts create mode 100644 test/e2e/new-link-behavior/material-ui.test.ts create mode 100644 test/e2e/new-link-behavior/material-ui/next.config.js create mode 100644 test/e2e/new-link-behavior/material-ui/pages/_app.js create mode 100644 test/e2e/new-link-behavior/material-ui/pages/_document.js create mode 100644 test/e2e/new-link-behavior/material-ui/pages/about.js create mode 100644 test/e2e/new-link-behavior/material-ui/pages/index.js create mode 100644 test/e2e/new-link-behavior/material-ui/src/Copyright.js create mode 100644 test/e2e/new-link-behavior/material-ui/src/Link.js create mode 100644 test/e2e/new-link-behavior/material-ui/src/ProTip.js create mode 100644 test/e2e/new-link-behavior/material-ui/src/createEmotionCache.js create mode 100644 test/e2e/new-link-behavior/material-ui/src/theme.js create mode 100644 test/e2e/new-link-behavior/stitches.test.ts create mode 100644 test/e2e/new-link-behavior/stitches/components/StitchesLogo.jsx create mode 100644 test/e2e/new-link-behavior/stitches/next.config.js create mode 100644 test/e2e/new-link-behavior/stitches/pages/_document.jsx create mode 100644 test/e2e/new-link-behavior/stitches/pages/about.jsx create mode 100644 test/e2e/new-link-behavior/stitches/pages/index.jsx create mode 100644 test/e2e/new-link-behavior/stitches/stitches.config.js create mode 100644 test/e2e/new-link-behavior/typescript.test.ts create mode 100644 test/e2e/new-link-behavior/typescript/next.config.js create mode 100644 test/e2e/new-link-behavior/typescript/pages/index.tsx create mode 100644 test/e2e/new-link-behavior/typescript/pages/ref.tsx create mode 100644 test/e2e/new-link-behavior/typescript/tsconfig.json create mode 100644 test/e2e/next-font/app/components/CompWithFonts.js create mode 100644 test/e2e/next-font/app/fonts/my-font.woff2 create mode 100644 test/e2e/next-font/app/fonts/my-other-font.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-100-italic.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-100.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-400-italic.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-400.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-900-italic.woff2 create mode 100644 test/e2e/next-font/app/fonts/roboto/roboto-900.woff2 create mode 100644 test/e2e/next-font/app/next.config.js create mode 100644 test/e2e/next-font/app/pages/_app.js create mode 100644 test/e2e/next-font/app/pages/variables.js create mode 100644 test/e2e/next-font/app/pages/with-fallback.js create mode 100644 test/e2e/next-font/app/pages/with-fonts.js create mode 100644 test/e2e/next-font/app/pages/with-google-fonts.js create mode 100644 test/e2e/next-font/app/pages/with-local-fonts.js create mode 100644 test/e2e/next-font/app/pages/without-fonts.js create mode 100644 test/e2e/next-font/basepath.test.ts create mode 100644 test/e2e/next-font/basepath/next.config.js create mode 100644 test/e2e/next-font/basepath/pages/index.js create mode 100644 test/e2e/next-font/font-loader-in-document-error.test.ts create mode 100644 test/e2e/next-font/font-loader-in-document/pages/_document.js create mode 100644 test/e2e/next-font/font-loader-in-document/pages/index.js create mode 100644 test/e2e/next-font/fonts/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIctxuTB_7Tp05GNyXkb24.woff2 create mode 100644 test/e2e/next-font/fonts/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIctxuTBv7Tp05GNyXkb24.woff2 create mode 100644 test/e2e/next-font/fonts/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIctxuTCf7Tp05GNyXk.woff2 create mode 100644 test/e2e/next-font/fonts/m8JVjfNVeKWVnh3QMuKkFcZVaUuH99GUDg.woff2 create mode 100644 test/e2e/next-font/google-fetch-error.test.ts create mode 100644 test/e2e/next-font/google-fetch-error/next.config.js create mode 100644 test/e2e/next-font/google-fetch-error/pages/index.js create mode 100644 test/e2e/next-font/google-font-mocked-responses.js create mode 100644 test/e2e/next-font/index.test.ts create mode 100644 test/e2e/next-font/with-font-declarations-file.test.ts create mode 100644 test/e2e/next-font/with-font-declarations-file/components/roboto-comp.js create mode 100644 test/e2e/next-font/with-font-declarations-file/fonts.js create mode 100644 test/e2e/next-font/with-font-declarations-file/my-font.woff2 create mode 100644 test/e2e/next-font/with-font-declarations-file/next.config.js create mode 100644 test/e2e/next-font/with-font-declarations-file/pages/_app.js create mode 100644 test/e2e/next-font/with-font-declarations-file/pages/inter.js create mode 100644 test/e2e/next-font/with-font-declarations-file/pages/local-font.js create mode 100644 test/e2e/next-font/with-font-declarations-file/pages/roboto.js create mode 100644 test/e2e/next-font/without-preloaded-fonts.test.ts create mode 100644 test/e2e/next-font/without-preloaded-fonts/next.config.js create mode 100644 test/e2e/next-font/without-preloaded-fonts/pages/_app.js create mode 100644 test/e2e/next-font/without-preloaded-fonts/pages/no-preload.js create mode 100644 test/e2e/next-font/without-preloaded-fonts/pages/without-fonts.js create mode 100644 test/e2e/next-head/app/components/meta.js create mode 100644 test/e2e/next-head/app/pages/_document.js create mode 100644 test/e2e/next-head/app/pages/index.js create mode 100644 test/e2e/next-head/index.test.ts create mode 100644 test/e2e/next-script/index.test.ts create mode 100644 test/e2e/no-eslint-warn-with-no-eslint-config/index.test.ts create mode 100644 test/e2e/nonce-head-manager/app/next.config.js create mode 100644 test/e2e/nonce-head-manager/app/pages/_document.js create mode 100644 test/e2e/nonce-head-manager/app/pages/csp.js create mode 100644 test/e2e/nonce-head-manager/app/pages/index.js create mode 100644 test/e2e/nonce-head-manager/app/public/src-1.js create mode 100644 test/e2e/nonce-head-manager/app/public/src-2.js create mode 100644 test/e2e/nonce-head-manager/index.test.ts create mode 100644 test/e2e/og-api/app/next.config.js create mode 100644 test/e2e/og-api/app/pages/api/og.js create mode 100644 test/e2e/og-api/app/pages/index.js create mode 100644 test/e2e/og-api/index.test.ts create mode 100644 test/e2e/postcss-config-cjs/app/pages/_app.js create mode 100644 test/e2e/postcss-config-cjs/app/pages/index.js create mode 100644 test/e2e/postcss-config-cjs/app/postcss.config.cjs create mode 100644 test/e2e/postcss-config-cjs/app/tailwind.config.cjs create mode 100644 test/e2e/postcss-config-cjs/index.test.ts create mode 100644 test/e2e/prerender-native-module/data.sqlite create mode 100644 test/e2e/prerender-native-module/pages/blog/[slug].js create mode 100644 test/e2e/prerender-native-module/pages/index.js create mode 100644 test/e2e/prerender/pages/another/index.js create mode 100644 test/e2e/prerender/pages/api-docs/[...slug].js create mode 100644 test/e2e/prerender/pages/api/bad.js create mode 100644 test/e2e/prerender/pages/api/enable.js create mode 100644 test/e2e/prerender/pages/api/manual-revalidate.js create mode 100644 test/e2e/prerender/pages/bad-gssp.js create mode 100644 test/e2e/prerender/pages/bad-ssr.js create mode 100644 test/e2e/prerender/pages/blocking-fallback-once/[slug].js create mode 100644 test/e2e/prerender/pages/blocking-fallback-some/[slug].js create mode 100644 test/e2e/prerender/pages/blocking-fallback/[slug].js create mode 100644 test/e2e/prerender/pages/blog/[post]/[comment].js create mode 100644 test/e2e/prerender/pages/blog/[post]/index.js create mode 100644 test/e2e/prerender/pages/blog/index.js create mode 100644 test/e2e/prerender/pages/catchall-explicit/[...slug].js create mode 100644 test/e2e/prerender/pages/catchall-optional/[[...slug]].js create mode 100644 test/e2e/prerender/pages/catchall/[...slug].js create mode 100644 test/e2e/prerender/pages/default-revalidate.js create mode 100644 test/e2e/prerender/pages/dynamic/[slug].js create mode 100644 test/e2e/prerender/pages/fallback-only/[slug].js create mode 100644 test/e2e/prerender/pages/index.js create mode 100644 test/e2e/prerender/pages/index/index.js.bak create mode 100644 test/e2e/prerender/pages/lang/[lang]/about.js create mode 100644 test/e2e/prerender/pages/large-page-data.js create mode 100644 test/e2e/prerender/pages/non-json-blocking/[p].js create mode 100644 test/e2e/prerender/pages/non-json/[p].js create mode 100644 test/e2e/prerender/pages/normal.js create mode 100644 test/e2e/prerender/pages/preview.js create mode 100644 test/e2e/prerender/pages/something.js create mode 100644 test/e2e/prerender/pages/user/[user]/profile.js create mode 100644 test/e2e/prerender/world.txt create mode 100644 test/e2e/proxy-request-with-middleware/app/middleware.js create mode 100644 test/e2e/proxy-request-with-middleware/app/pages/api/index.js create mode 100644 test/e2e/proxy-request-with-middleware/app/pages/api/post.js create mode 100644 test/e2e/proxy-request-with-middleware/test/index.test.ts create mode 100644 test/e2e/reload-scroll-backforward-restoration/index.test.ts create mode 100644 test/e2e/reload-scroll-backforward-restoration/next.config.js create mode 100644 test/e2e/reload-scroll-backforward-restoration/pages/[id].js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/middleware.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/next.config.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/another.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/blog/[slug].js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/index.js create mode 100644 test/e2e/skip-trailing-slash-redirect/index.test.ts create mode 100644 test/e2e/ssr-react-context/app/context.js create mode 100644 test/e2e/ssr-react-context/app/pages/_app.js create mode 100644 test/e2e/ssr-react-context/app/pages/consumer.js create mode 100644 test/e2e/ssr-react-context/app/pages/index.js create mode 100644 test/e2e/ssr-react-context/index.test.ts create mode 100644 test/e2e/streaming-ssr/custom-server/next.config.js create mode 100644 test/e2e/streaming-ssr/custom-server/pages/index.js create mode 100644 test/e2e/streaming-ssr/custom-server/server.js create mode 100644 test/e2e/streaming-ssr/index.test.ts create mode 100644 test/e2e/streaming-ssr/streaming-ssr/next.config.js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/api/user/[id].js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/api/user/login.js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/hello.js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/index.js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/multi-byte.js create mode 100644 test/e2e/streaming-ssr/streaming-ssr/pages/router.js create mode 100644 test/e2e/styled-jsx/app/.npmrc create mode 100644 test/e2e/styled-jsx/app/node_modules_bak/my-comps/button.js create mode 100644 test/e2e/styled-jsx/app/pages/amp.js create mode 100644 test/e2e/styled-jsx/app/pages/index.js create mode 100644 test/e2e/styled-jsx/index.test.ts create mode 100644 test/e2e/swc-warnings/index.test.ts create mode 100644 test/e2e/switchable-runtime/app/edge-rsc/page.server.js.bak create mode 100644 test/e2e/switchable-runtime/app/layout.js create mode 100644 test/e2e/switchable-runtime/app/legacy-extension/page.server.js create mode 100644 test/e2e/switchable-runtime/app/node-rsc-isr/page.js create mode 100644 test/e2e/switchable-runtime/app/node-rsc-ssg/page.js create mode 100644 test/e2e/switchable-runtime/app/node-rsc-ssr/page.js create mode 100644 test/e2e/switchable-runtime/app/node-rsc/page.js create mode 100644 test/e2e/switchable-runtime/index.test.ts create mode 100644 test/e2e/switchable-runtime/next.config.js create mode 100644 test/e2e/switchable-runtime/pages/api/edge.js create mode 100644 test/e2e/switchable-runtime/pages/api/hello.js create mode 100644 test/e2e/switchable-runtime/pages/api/node.js create mode 100644 test/e2e/switchable-runtime/pages/api/switch-in-dev-same-content.js create mode 100644 test/e2e/switchable-runtime/pages/api/switch-in-dev.js create mode 100644 test/e2e/switchable-runtime/pages/api/syntax-error-in-dev.js create mode 100644 test/e2e/switchable-runtime/pages/edge.js create mode 100644 test/e2e/switchable-runtime/pages/edge/[id].js create mode 100644 test/e2e/switchable-runtime/pages/edge/foo.js create mode 100644 test/e2e/switchable-runtime/pages/invalid-runtime.js create mode 100644 test/e2e/switchable-runtime/pages/node-ssg.js create mode 100644 test/e2e/switchable-runtime/pages/node-ssr.js create mode 100644 test/e2e/switchable-runtime/pages/node.js create mode 100644 test/e2e/switchable-runtime/pages/static.js create mode 100644 test/e2e/switchable-runtime/pages/switch-in-dev.js create mode 100644 test/e2e/switchable-runtime/utils/runtime.js create mode 100644 test/e2e/switchable-runtime/utils/time.js create mode 100644 test/e2e/transpile-packages/index.test.ts create mode 100644 test/e2e/transpile-packages/npm/next.config.js create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/global-css.js create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/global-scss.js create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/global.css create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/global.scss create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/module-css.js create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/module-scss.js create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/package.json create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.css create mode 100644 test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.scss create mode 100644 test/e2e/transpile-packages/npm/pages/css-modules.js create mode 100644 test/e2e/transpile-packages/npm/pages/global-css.js create mode 100644 test/e2e/transpile-packages/npm/pages/global-scss.js create mode 100644 test/e2e/transpile-packages/npm/pages/scss-modules.js create mode 100644 test/e2e/type-module-interop/index.test.ts create mode 100644 test/e2e/undici-fetch/index.test.ts create mode 100644 test/e2e/yarn-pnp/test/pwa-example.test.ts create mode 100644 test/e2e/yarn-pnp/test/utils.ts create mode 100644 test/e2e/yarn-pnp/test/with-eslint.test.ts create mode 100644 test/e2e/yarn-pnp/test/with-mdx.test.ts create mode 100644 test/e2e/yarn-pnp/test/with-next-sass.test.ts diff --git a/package-lock.json b/package-lock.json index 365bcf7f19..39c03ecb3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5714,7 +5714,7 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { "version": "18.0.25", @@ -5746,7 +5746,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "node_modules/@types/semver": { "version": "7.3.13", @@ -9562,7 +9562,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "devOptional": true + "dev": true }, "node_modules/custom-routes": { "resolved": "demos/custom-routes", @@ -11446,90 +11446,6 @@ "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/eslint-plugin-n": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.5.1.tgz", - "integrity": "sha512-kAd+xhZm7brHoFLzKLB7/FGRFJNg/srmv67mqb7tto22rpr4wv/LV6RuXzAfv3jbab7+k1wi42PsIhGviywaaw==", - "dev": true, - "peer": true, - "dependencies": { - "builtins": "^5.0.1", - "eslint-plugin-es": "^4.1.0", - "eslint-utils": "^3.0.0", - "ignore": "^5.1.1", - "is-core-module": "^2.11.0", - "minimatch": "^3.1.2", - "resolve": "^1.22.1", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-n/node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "peer": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "peer": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-promise": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", @@ -13858,7 +13774,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "devOptional": true + "dev": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -21405,7 +21321,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "devOptional": true, + "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -28377,7 +28293,7 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "@types/react": { "version": "18.0.25", @@ -28409,7 +28325,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "@types/semver": { "version": "7.3.13", @@ -28729,8 +28645,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -28787,8 +28702,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "dev": true, - "requires": {} + "dev": true }, "ansi-align": { "version": "3.0.1", @@ -30819,8 +30733,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.2.0.tgz", "integrity": "sha512-NkANeMnaHrlaSSlpKGyvn2R4rqUDeE/9E5YHx+b4nwo0R8dZyAqcih8/gxpCZvqWP9Vf6xuLpMSzSgdVEIM78g==", - "dev": true, - "requires": {} + "dev": true }, "cp-file": { "version": "9.1.0", @@ -31364,7 +31277,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "devOptional": true + "dev": true }, "custom-routes": { "version": "file:demos/custom-routes", @@ -32549,15 +32462,13 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-config-standard": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-formatter-codeframe": { "version": "7.32.1", @@ -32942,74 +32853,11 @@ "mdast-util-from-markdown": "^0.8.5" } }, - "eslint-plugin-n": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.5.1.tgz", - "integrity": "sha512-kAd+xhZm7brHoFLzKLB7/FGRFJNg/srmv67mqb7tto22rpr4wv/LV6RuXzAfv3jbab7+k1wi42PsIhGviywaaw==", - "dev": true, - "peer": true, - "requires": { - "builtins": "^5.0.1", - "eslint-plugin-es": "^4.1.0", - "eslint-utils": "^3.0.0", - "ignore": "^5.1.1", - "is-core-module": "^2.11.0", - "minimatch": "^3.1.2", - "resolve": "^1.22.1", - "semver": "^7.3.8" - }, - "dependencies": { - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "peer": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "peer": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "peer": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "peer": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "eslint-plugin-promise": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react": { "version": "7.31.10", @@ -33067,8 +32915,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-unicorn": { "version": "43.0.2", @@ -34618,7 +34465,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "devOptional": true + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -35698,8 +35545,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "27.5.1", @@ -40294,7 +40140,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "devOptional": true, + "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -41246,8 +41092,7 @@ "styled-jsx": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz", - "integrity": "sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==", - "requires": {} + "integrity": "sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==" }, "supports-color": { "version": "9.2.3", @@ -42008,8 +41853,7 @@ "ws": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "requires": {} + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==" } } }, @@ -42137,8 +41981,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util-deprecate": { "version": "1.0.2", @@ -42502,8 +42345,7 @@ "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "dev": true, - "requires": {} + "dev": true }, "xdg-basedir": { "version": "4.0.0", diff --git a/test/e2e/basepath/external/page.html b/test/e2e/basepath/external/page.html new file mode 100644 index 0000000000..9785532024 --- /dev/null +++ b/test/e2e/basepath/external/page.html @@ -0,0 +1,5 @@ + + + hello from external + + diff --git a/test/e2e/basepath/pages/404.js b/test/e2e/basepath/pages/404.js new file mode 100644 index 0000000000..a4d7c60867 --- /dev/null +++ b/test/e2e/basepath/pages/404.js @@ -0,0 +1,5 @@ +import NextError from 'next/error' + +export default function Page() { + return +} diff --git a/test/e2e/basepath/pages/[slug].js b/test/e2e/basepath/pages/[slug].js new file mode 100644 index 0000000000..a13cdf84dc --- /dev/null +++ b/test/e2e/basepath/pages/[slug].js @@ -0,0 +1,4 @@ +import { useRouter } from 'next/router' + +const Page = () =>

slug: {useRouter().query.slug}

+export default Page diff --git a/test/e2e/basepath/pages/_app.js b/test/e2e/basepath/pages/_app.js new file mode 100644 index 0000000000..a513b6f42b --- /dev/null +++ b/test/e2e/basepath/pages/_app.js @@ -0,0 +1,52 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' + +// We use session storage for the event log so that it will survive +// page reloads, which happen for instance during routeChangeError +const EVENT_LOG_KEY = 'router-event-log' + +function getEventLog() { + const data = sessionStorage.getItem(EVENT_LOG_KEY) + return data ? JSON.parse(data) : [] +} + +function clearEventLog() { + sessionStorage.removeItem(EVENT_LOG_KEY) +} + +function addEvent(data) { + const eventLog = getEventLog() + eventLog.push(data) + sessionStorage.setItem(EVENT_LOG_KEY, JSON.stringify(eventLog)) +} + +if (typeof window !== 'undefined') { + // global functions introduced to interface with the test infrastructure + window._clearEventLog = clearEventLog + window._getEventLog = getEventLog +} + +function useLoggedEvent(event, serializeArgs = (...args) => args) { + const router = useRouter() + useEffect(() => { + const logEvent = (...args) => { + addEvent([event, ...serializeArgs(...args)]) + } + router.events.on(event, logEvent) + return () => router.events.off(event, logEvent) + }, [event, router.events, serializeArgs]) +} + +function serializeErrorEventArgs(err, url, properties) { + return [err.message, err.cancelled, url, properties] +} + +export default function MyApp({ Component, pageProps }) { + useLoggedEvent('routeChangeStart') + useLoggedEvent('routeChangeComplete') + useLoggedEvent('routeChangeError', serializeErrorEventArgs) + useLoggedEvent('beforeHistoryChange') + useLoggedEvent('hashChangeStart') + useLoggedEvent('hashChangeComplete') + return +} diff --git a/test/e2e/basepath/pages/absolute-url-basepath.js b/test/e2e/basepath/pages/absolute-url-basepath.js new file mode 100644 index 0000000000..09e8e607e1 --- /dev/null +++ b/test/e2e/basepath/pages/absolute-url-basepath.js @@ -0,0 +1,25 @@ +import React from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' + +export async function getServerSideProps({ query: { port } }) { + if (!port) { + throw new Error('port required') + } + return { props: { port } } +} + +export default function Page({ port }) { + const router = useRouter() + return ( + <> + + http://localhost:{port} + {router.basePath}/something-else + + + ) +} diff --git a/test/e2e/basepath/pages/absolute-url-no-basepath.js b/test/e2e/basepath/pages/absolute-url-no-basepath.js new file mode 100644 index 0000000000..fe56f97153 --- /dev/null +++ b/test/e2e/basepath/pages/absolute-url-no-basepath.js @@ -0,0 +1,22 @@ +import React from 'react' +import Link from 'next/link' + +export async function getServerSideProps({ query: { port } }) { + if (!port) { + throw new Error('port required') + } + return { props: { port } } +} + +export default function Page({ port }) { + return ( + <> + + http://localhost:{port}/rewrite-no-basepath + + + ) +} diff --git a/test/e2e/basepath/pages/absolute-url.js b/test/e2e/basepath/pages/absolute-url.js new file mode 100644 index 0000000000..2ff305be03 --- /dev/null +++ b/test/e2e/basepath/pages/absolute-url.js @@ -0,0 +1,16 @@ +import React from 'react' +import Link from 'next/link' + +export default function Page() { + return ( + <> + + https://vercel.com/ + +
+ + mailto:idk@idk.com + + + ) +} diff --git a/test/e2e/basepath/pages/amp-hybrid.js b/test/e2e/basepath/pages/amp-hybrid.js new file mode 100644 index 0000000000..c2e7ce0c2d --- /dev/null +++ b/test/e2e/basepath/pages/amp-hybrid.js @@ -0,0 +1,4 @@ +export const config = { amp: 'hybrid' } + +const Page = () =>

Hello amp

+export default Page diff --git a/test/e2e/basepath/pages/catchall/[...parts].js b/test/e2e/basepath/pages/catchall/[...parts].js new file mode 100644 index 0000000000..899aeb95b9 --- /dev/null +++ b/test/e2e/basepath/pages/catchall/[...parts].js @@ -0,0 +1,4 @@ +import { useRouter } from 'next/router' + +const Page = () =>

parts: {useRouter().query.parts?.join('/')}

+export default Page diff --git a/test/e2e/basepath/pages/docs/another.js b/test/e2e/basepath/pages/docs/another.js new file mode 100644 index 0000000000..ad1c4c7547 --- /dev/null +++ b/test/e2e/basepath/pages/docs/another.js @@ -0,0 +1,2 @@ +const Page = () =>

hello from another

+export default Page diff --git a/test/e2e/basepath/pages/error-route.js b/test/e2e/basepath/pages/error-route.js new file mode 100644 index 0000000000..4e95173076 --- /dev/null +++ b/test/e2e/basepath/pages/error-route.js @@ -0,0 +1,8 @@ +export async function getServerSideProps() { + // We will use this route to simulate a route change errors + throw new Error('KABOOM!') +} + +export default function Page() { + return null +} diff --git a/test/e2e/basepath/pages/external-and-back.js b/test/e2e/basepath/pages/external-and-back.js new file mode 100644 index 0000000000..c2932502b2 --- /dev/null +++ b/test/e2e/basepath/pages/external-and-back.js @@ -0,0 +1,12 @@ +const Page = ({ from }) => ( +
+

{from}

+ External link +
+) + +Page.getInitialProps = () => { + return { from: typeof window === 'undefined' ? 'server' : 'client' } +} + +export default Page diff --git a/test/e2e/basepath/pages/gsp.js b/test/e2e/basepath/pages/gsp.js new file mode 100644 index 0000000000..fd6575b8b1 --- /dev/null +++ b/test/e2e/basepath/pages/gsp.js @@ -0,0 +1,18 @@ +import { useRouter } from 'next/router' + +export const getStaticProps = () => { + return { + props: { + hello: 'world', + random: Math.random(), + }, + } +} + +export default (props) => ( + <> +

getStaticProps

+

{JSON.stringify(props)}

+
{useRouter().pathname}
+ +) diff --git a/test/e2e/basepath/pages/gssp.js b/test/e2e/basepath/pages/gssp.js new file mode 100644 index 0000000000..40a5f611ca --- /dev/null +++ b/test/e2e/basepath/pages/gssp.js @@ -0,0 +1,19 @@ +import { useRouter } from 'next/router' + +export const getServerSideProps = () => { + return { + props: { + hello: 'world', + random: Math.random(), + }, + } +} + +export default (props) => ( + <> +

getServerSideProps

+

{JSON.stringify(props)}

+
{useRouter().pathname}
+
{useRouter().asPath}
+ +) diff --git a/test/e2e/basepath/pages/hello.js b/test/e2e/basepath/pages/hello.js new file mode 100644 index 0000000000..966ccab36d --- /dev/null +++ b/test/e2e/basepath/pages/hello.js @@ -0,0 +1,67 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +const Page = () => ( + <> + +

Hello World

+ +
+ +

getStaticProps

+ +
+ +

getServerSideProps

+ +
+ +

dynamic page

+ +
+ +

catchall page

+ +
+ +

index getStaticProps

+ +
+ +

nested index getStaticProps

+ + + Hash Link + +
+
{useRouter().basePath}
+
{useRouter().pathname}
+
{ + throw new Error('oops heres an error') + }} + > + click me for error +
+
+
{useRouter().asPath}
+ +

Slow route

+ + +

Error route

+ + +

Hash change

+ + + to something else + + +) +export default Page diff --git a/test/e2e/basepath/pages/index.js b/test/e2e/basepath/pages/index.js new file mode 100644 index 0000000000..52c4affcf9 --- /dev/null +++ b/test/e2e/basepath/pages/index.js @@ -0,0 +1,28 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +export const getStaticProps = () => { + return { + props: { + nested: false, + hello: 'hello', + }, + } +} + +export default function Index({ hello, nested }) { + const { query, pathname, asPath } = useRouter() + return ( + <> +

index page

+

{nested ? 'yes' : 'no'}

+

{hello} world

+

{JSON.stringify(query)}

+

{pathname}

+

{asPath}

+ + to /hello + + + ) +} diff --git a/test/e2e/basepath/pages/index/index.js.bak b/test/e2e/basepath/pages/index/index.js.bak new file mode 100644 index 0000000000..c9b946b00e --- /dev/null +++ b/test/e2e/basepath/pages/index/index.js.bak @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router' + +export const getStaticProps = () => { + return { + props: { + nested: true, + hello: 'hello', + }, + } +} + +export default function Index({ hello, nested }) { + const { query, pathname } = useRouter() + return ( + <> +

{nested ? 'yes' : 'no'}

+

{hello} world

+

{JSON.stringify(query)}

+

{pathname}

+ + ) +} diff --git a/test/e2e/basepath/pages/invalid-manual-basepath.js b/test/e2e/basepath/pages/invalid-manual-basepath.js new file mode 100644 index 0000000000..df7b075e3c --- /dev/null +++ b/test/e2e/basepath/pages/invalid-manual-basepath.js @@ -0,0 +1,12 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +const Page = () => ( + <> + +

Hello World

+ + +) + +export default Page diff --git a/test/e2e/basepath/pages/link-to-root.js b/test/e2e/basepath/pages/link-to-root.js new file mode 100644 index 0000000000..d796894f5b --- /dev/null +++ b/test/e2e/basepath/pages/link-to-root.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + + back + + + ) +} diff --git a/test/e2e/basepath/pages/other-page.js b/test/e2e/basepath/pages/other-page.js new file mode 100644 index 0000000000..3504808922 --- /dev/null +++ b/test/e2e/basepath/pages/other-page.js @@ -0,0 +1,2 @@ +const Page = () =>

Hello Other

+export default Page diff --git a/test/e2e/basepath/pages/slow-route.js b/test/e2e/basepath/pages/slow-route.js new file mode 100644 index 0000000000..6dcc6898c9 --- /dev/null +++ b/test/e2e/basepath/pages/slow-route.js @@ -0,0 +1,10 @@ +export async function getServerSideProps() { + // We will use this route to simulate a route cancellation error + // by clicking its link twice in rapid succession + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { props: {} } +} + +export default function Page() { + return null +} diff --git a/test/e2e/basepath/pages/something-else.js b/test/e2e/basepath/pages/something-else.js new file mode 100644 index 0000000000..bc9d681475 --- /dev/null +++ b/test/e2e/basepath/pages/something-else.js @@ -0,0 +1,3 @@ +export default function Page() { + return

something else

+} diff --git a/test/e2e/basepath/pages/ssr.js b/test/e2e/basepath/pages/ssr.js new file mode 100644 index 0000000000..b1ed3c0c75 --- /dev/null +++ b/test/e2e/basepath/pages/ssr.js @@ -0,0 +1,11 @@ +function SSRPage({ test }) { + return

{test}

+} + +SSRPage.getInitialProps = () => { + return { + test: 'hello', + } +} + +export default SSRPage diff --git a/test/e2e/basepath/public/data.txt b/test/e2e/basepath/public/data.txt new file mode 100644 index 0000000000..95d09f2b10 --- /dev/null +++ b/test/e2e/basepath/public/data.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/test/e2e/browserslist-extends/index.test.ts b/test/e2e/browserslist-extends/index.test.ts new file mode 100644 index 0000000000..9b915c1f8f --- /dev/null +++ b/test/e2e/browserslist-extends/index.test.ts @@ -0,0 +1,38 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('browserslist-extends', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + import styles from './index.module.css' + + export default function Page() { + return

hello world

+ } + `, + 'pages/index.module.css': ` + .hello { + color: pink; + } + `, + }, + dependencies: { + 'browserslist-config-google': '^3.0.1', + }, + packageJson: { + browserslist: ['extends browserslist-config-google'], + }, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('hello world') + }) +}) diff --git a/test/e2e/browserslist/app/pages/index.js b/test/e2e/browserslist/app/pages/index.js new file mode 100644 index 0000000000..5a2db92580 --- /dev/null +++ b/test/e2e/browserslist/app/pages/index.js @@ -0,0 +1,22 @@ +import React, { useEffect } from 'react' +const helloWorld = 'hello world' + +class MyComp extends React.Component { + render() { + return

Hello World

+ } +} + +export default function Page() { + useEffect(() => { + ;(async () => { + console.log(helloWorld) + })() + }, []) + return ( + <> + {helloWorld} + + + ) +} diff --git a/test/e2e/browserslist/browserslist.test.ts b/test/e2e/browserslist/browserslist.test.ts new file mode 100644 index 0000000000..dc61e49300 --- /dev/null +++ b/test/e2e/browserslist/browserslist.test.ts @@ -0,0 +1,43 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP, fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import cheerio from 'cheerio' +const appDir = path.join(__dirname, 'app') + +describe('Browserslist', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + '.browserslistrc': 'Chrome 73', + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should apply browserslist target', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + + let finished = false + await Promise.all( + $('script') + .toArray() + .map(async (el) => { + const src = $(el).attr('src') + if (!src) return + if (src.includes('/index')) { + const res = await fetchViaHTTP(next.url, src) + const code = await res.text() + expect(code).toMatch('()=>') + finished = true + } + }) + ) + expect(finished).toBe(true) + }) +}) diff --git a/test/e2e/browserslist/legacybrowsers-false.test.ts b/test/e2e/browserslist/legacybrowsers-false.test.ts new file mode 100644 index 0000000000..4b41225a29 --- /dev/null +++ b/test/e2e/browserslist/legacybrowsers-false.test.ts @@ -0,0 +1,42 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP, fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import cheerio from 'cheerio' +const appDir = path.join(__dirname, 'app') + +describe('legacyBrowsers: false', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should apply legacyBrowsers: false by default', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + + let finished = false + await Promise.all( + $('script') + .toArray() + .map(async (el) => { + const src = $(el).attr('src') + if (!src) return + if (src.includes('/index')) { + const res = await fetchViaHTTP(next.url, src) + const code = await res.text() + expect(code).toMatch('()=>') + finished = true + } + }) + ) + expect(finished).toBe(true) + }) +}) diff --git a/test/e2e/browserslist/legacybrowsers-true.test.ts b/test/e2e/browserslist/legacybrowsers-true.test.ts new file mode 100644 index 0000000000..a7b7524fb2 --- /dev/null +++ b/test/e2e/browserslist/legacybrowsers-true.test.ts @@ -0,0 +1,47 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP, fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import cheerio from 'cheerio' +const appDir = path.join(__dirname, 'app') + +describe('legacyBrowsers: true', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + }, + nextConfig: { + experimental: { + legacyBrowsers: true, + }, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should apply legacyBrowsers: true', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + + let finished = false + await Promise.all( + $('script') + .toArray() + .map(async (el) => { + const src = $(el).attr('src') + if (!src) return + if (src.includes('/index')) { + const res = await fetchViaHTTP(next.url, src) + const code = await res.text() + expect(code).not.toMatch('()=>') + finished = true + } + }) + ) + expect(finished).toBe(true) + }) +}) diff --git a/test/e2e/config-promise-export/async-function.test.ts b/test/e2e/config-promise-export/async-function.test.ts new file mode 100644 index 0000000000..0869155f1c --- /dev/null +++ b/test/e2e/config-promise-export/async-function.test.ts @@ -0,0 +1,33 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('async export', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = async () => { + return { + basePath: '/docs' + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/docs') + expect(html).toContain('hello world') + }) +}) diff --git a/test/e2e/config-promise-export/promise.test.ts b/test/e2e/config-promise-export/promise.test.ts new file mode 100644 index 0000000000..5b7889cc94 --- /dev/null +++ b/test/e2e/config-promise-export/promise.test.ts @@ -0,0 +1,33 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('promise export', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + 'next.config.js': ` + module.exports = new Promise((resolve) => { + resolve({ + basePath: '/docs' + }) + }) + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/docs') + expect(html).toContain('hello world') + }) +}) diff --git a/test/e2e/dynamic-route-interpolation/index.test.ts b/test/e2e/dynamic-route-interpolation/index.test.ts new file mode 100644 index 0000000000..a7fb9b8216 --- /dev/null +++ b/test/e2e/dynamic-route-interpolation/index.test.ts @@ -0,0 +1,91 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' + +describe('Dynamic Route Interpolation', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/blog/[slug].js': ` + import Link from "next/link" + import { useRouter } from "next/router" + + export function getServerSideProps({ params }) { + return { props: { slug: params.slug, now: Date.now() } } + } + + export default function Page(props) { + const router = useRouter() + return ( + <> +

{props.slug}

+ + {props.now} + + + ) + } + `, + + 'pages/api/dynamic/[slug].js': ` + export default function Page(req, res) { + const { slug } = req.query + res.end('slug: ' + slug) + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/blog/a') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('a') + }) + + it('should work with parameter itself', async () => { + const html = await renderViaHTTP(next.url, '/blog/[slug]') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('[slug]') + }) + + it('should work with brackets', async () => { + const html = await renderViaHTTP(next.url, '/blog/[abc]') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('[abc]') + }) + + it('should work with parameter itself in API routes', async () => { + const text = await renderViaHTTP(next.url, '/api/dynamic/[slug]') + expect(text).toBe('slug: [slug]') + }) + + it('should work with brackets in API routes', async () => { + const text = await renderViaHTTP(next.url, '/api/dynamic/[abc]') + expect(text).toBe('slug: [abc]') + }) + + it('should bust data cache', async () => { + const browser = await webdriver(next.url, '/blog/login') + await browser.elementById('now').click() // fetch data once + const text = await browser.elementById('now').text() + await browser.elementById('now').click() // fetch data again + await browser.waitForElementByCss(`#now:not(:text("${text}"))`) + await browser.close() + }) + + it('should bust data cache with symbol', async () => { + const browser = await webdriver(next.url, '/blog/@login') + await browser.elementById('now').click() // fetch data once + const text = await browser.elementById('now').text() + await browser.elementById('now').click() // fetch data again + await browser.waitForElementByCss(`#now:not(:text("${text}"))`) + await browser.close() + }) +}) diff --git a/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/edge.js b/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/edge.js new file mode 100644 index 0000000000..26bcb1402f --- /dev/null +++ b/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/edge.js @@ -0,0 +1,11 @@ +export default async (req) => { + if (!req.body) { + return new Response('Body is required', { status: 400 }) + } + + return new Response(`got: ${await req.text()}`) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/index.js b/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/index.js new file mode 100644 index 0000000000..26bcb1402f --- /dev/null +++ b/test/e2e/edge-api-endpoints-can-receive-body/app/pages/api/index.js @@ -0,0 +1,11 @@ +export default async (req) => { + if (!req.body) { + return new Response('Body is required', { status: 400 }) + } + + return new Response(`got: ${await req.text()}`) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/edge-api-endpoints-can-receive-body/index.test.ts b/test/e2e/edge-api-endpoints-can-receive-body/index.test.ts new file mode 100644 index 0000000000..c33d6b1056 --- /dev/null +++ b/test/e2e/edge-api-endpoints-can-receive-body/index.test.ts @@ -0,0 +1,53 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import path from 'path' + +describe('Edge API endpoints can receive body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/edge.js': new FileRef( + path.resolve(__dirname, './app/pages/api/edge.js') + ), + 'pages/api/index.js': new FileRef( + path.resolve(__dirname, './app/pages/api/index.js') + ), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('reads the body as text', async () => { + const res = await fetchViaHTTP( + next.url, + '/api/edge', + {}, + { + body: 'hello, world.', + method: 'POST', + } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('got: hello, world.') + }) + + it('reads the body from index', async () => { + const res = await fetchViaHTTP( + next.url, + '/api', + {}, + { + body: 'hello, world.', + method: 'POST', + } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('got: hello, world.') + }) +}) diff --git a/test/e2e/edge-async-local-storage/index.test.ts b/test/e2e/edge-async-local-storage/index.test.ts new file mode 100644 index 0000000000..39c2798f18 --- /dev/null +++ b/test/e2e/edge-async-local-storage/index.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable jest/valid-expect-in-promise */ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('edge api can use async local storage', () => { + let next: NextInstance + + const cases = [ + { + title: 'a single instance', + code: ` + export const config = { runtime: 'experimental-edge' } + const storage = new AsyncLocalStorage() + + export default async function handler(request) { + const id = request.headers.get('req-id') + return storage.run({ id }, async () => { + await getSomeData() + return Response.json(storage.getStore()) + }) + } + + async function getSomeData() { + try { + const response = await fetch('https://example.vercel.sh') + await response.text() + } finally { + return true + } + } + `, + expectResponse: (response, id) => + expect(response).toMatchObject({ status: 200, json: { id } }), + }, + { + title: 'multiple instances', + code: ` + export const config = { runtime: 'experimental-edge' } + const topStorage = new AsyncLocalStorage() + + export default async function handler(request) { + const id = request.headers.get('req-id') + return topStorage.run({ id }, async () => { + const nested = await getSomeData(id) + return Response.json({ ...nested, ...topStorage.getStore() }) + }) + } + + async function getSomeData(id) { + const nestedStorage = new AsyncLocalStorage() + return nestedStorage.run('nested-' + id, async () => { + try { + const response = await fetch('https://example.vercel.sh') + await response.text() + } finally { + return { nestedId: nestedStorage.getStore() } + } + }) + } + `, + expectResponse: (response, id) => + expect(response).toMatchObject({ + status: 200, + json: { id: id, nestedId: `nested-${id}` }, + }), + }, + ] + + afterEach(() => next.destroy()) + + it.each(cases)( + 'cans use $title per request', + async ({ code, expectResponse }) => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function () { return
Hello, world!
} + `, + 'pages/api/async.js': code, + }, + }) + const ids = Array.from({ length: 100 }, (_, i) => `req-${i}`) + + const responses = await Promise.all( + ids.map((id) => + fetchViaHTTP( + next.url, + '/api/async', + {}, + { headers: { 'req-id': id } } + ).then((response) => + response.headers.get('content-type')?.startsWith('application/json') + ? response.json().then((json) => ({ + status: response.status, + json, + text: null, + })) + : response.text().then((text) => ({ + status: response.status, + json: null, + text, + })) + ) + ) + ) + const rankById = new Map(ids.map((id, rank) => [id, rank])) + + const errors: Error[] = [] + for (const [rank, response] of responses.entries()) { + try { + expectResponse(response, ids[rank]) + } catch (error) { + const received = response.json?.id + console.log( + `response #${rank} has id from request #${rankById.get(received)}` + ) + errors.push(error as Error) + } + } + if (errors.length) { + throw errors[0] + } + } + ) +}) diff --git a/test/e2e/edge-can-read-request-body/app/.gitignore b/test/e2e/edge-can-read-request-body/app/.gitignore new file mode 100644 index 0000000000..e985853ed8 --- /dev/null +++ b/test/e2e/edge-can-read-request-body/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/test/e2e/edge-can-read-request-body/app/middleware.js b/test/e2e/edge-can-read-request-body/app/middleware.js new file mode 100644 index 0000000000..bbddddb07e --- /dev/null +++ b/test/e2e/edge-can-read-request-body/app/middleware.js @@ -0,0 +1,49 @@ +// @ts-check + +import { NextResponse } from 'next/server' + +/** + * @param {NextRequest} req + */ +export default async function middleware(req) { + const res = NextResponse.next() + res.headers.set('x-incoming-content-type', req.headers.get('content-type')) + + const handler = + bodyHandlers[req.nextUrl.searchParams.get('middleware-handler')] + const headers = await handler?.(req) + for (const [key, value] of headers ?? []) { + res.headers.set(key, value) + } + + return res +} + +/** + * @typedef {import('next/server').NextRequest} NextRequest + * @typedef {(req: NextRequest) => Promise<[string, string][]>} Handler + * @type {Record} + */ +const bodyHandlers = { + json: async (req) => { + const json = await req.json() + return [ + ['x-req-type', 'json'], + ['x-serialized', JSON.stringify(json)], + ] + }, + text: async (req) => { + const text = await req.text() + return [ + ['x-req-type', 'text'], + ['x-serialized', text], + ] + }, + formData: async (req) => { + const formData = await req.formData() + return [ + ['x-req-type', 'formData'], + ['x-serialized', JSON.stringify(Object.fromEntries(formData))], + ] + }, +} diff --git a/test/e2e/edge-can-read-request-body/app/pages/api/nothing.js b/test/e2e/edge-can-read-request-body/app/pages/api/nothing.js new file mode 100644 index 0000000000..7c595baeca --- /dev/null +++ b/test/e2e/edge-can-read-request-body/app/pages/api/nothing.js @@ -0,0 +1,3 @@ +export default (_req, res) => { + res.send('ok') +} diff --git a/test/e2e/edge-can-read-request-body/index.test.ts b/test/e2e/edge-can-read-request-body/index.test.ts new file mode 100644 index 0000000000..f57a02cf8c --- /dev/null +++ b/test/e2e/edge-can-read-request-body/index.test.ts @@ -0,0 +1,124 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import FormData from 'form-data' +import path from 'path' + +async function serialize(response: Response) { + return { + text: await response.text(), + headers: Object.fromEntries(response.headers), + status: response.status, + } +} + +describe('Edge can read request body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.resolve(__dirname, './app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('renders the static page', async () => { + const html = await renderViaHTTP(next.url, '/api/nothing') + expect(html).toContain('ok') + }) + + describe('middleware', () => { + it('reads a JSON body', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/nothing?middleware-handler=json', + null, + { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + } + ) + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'json', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + + it('reads a text body', async () => { + try { + const response = await fetchViaHTTP( + next.url, + '/api/nothing?middleware-handler=text', + null, + { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + } + ) + + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'text', + 'x-serialized': '{"hello":"world"}', + }, + }) + } catch (err) { + console.log('FAILED', err) + } + }) + + it('reads an URL encoded form data', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/nothing?middleware-handler=formData', + null, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ hello: 'world' }).toString(), + } + ) + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'formData', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + + it('reads a multipart form data', async () => { + const formData = new FormData() + formData.append('hello', 'world') + + const response = await fetchViaHTTP( + next.url, + '/api/nothing?middleware-handler=formData', + null, + { + method: 'POST', + body: formData, + } + ) + + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'formData', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + }) +}) diff --git a/test/e2e/edge-can-use-wasm-files/add.wasm b/test/e2e/edge-can-use-wasm-files/add.wasm new file mode 100644 index 0000000000000000000000000000000000000000..f22496d0b6c87704a3844134af2a9fad47fa344c GIT binary patch literal 126 zcmY+)F%E)25CzcxcYuwog{_@8@C=+}7_*ZYlLaC+R?E>mnzU4}d9bw*08e2AMpjml z05&ZblC2Pz?kbhTw*8PQj>db_6)*Gq8<13=Zi_x_bz!fX?PKawmJlsxohJwTbJ%CZ I4Fg~44-%gmga7~l literal 0 HcmV?d00001 diff --git a/test/e2e/edge-can-use-wasm-files/index.test.ts b/test/e2e/edge-can-use-wasm-files/index.test.ts new file mode 100644 index 0000000000..48050bc0c5 --- /dev/null +++ b/test/e2e/edge-can-use-wasm-files/index.test.ts @@ -0,0 +1,150 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +function extractJSON(response) { + return JSON.parse(response.headers.get('data') ?? '{}') +} + +function baseNextConfig(): Parameters[0] { + return { + files: { + 'src/add.wasm': new FileRef(path.join(__dirname, './add.wasm')), + 'src/add.js': ` + import wasm from './add.wasm?module' + const instance$ = WebAssembly.instantiate(wasm); + + export async function increment(a) { + const { exports } = await instance$; + return exports.add_one(a); + } + `, + 'pages/index.js': ` + export default function () { return
Hello, world!
} + `, + 'middleware.js': ` + import { increment } from './src/add.js' + export default async function middleware(request) { + const input = Number(request.nextUrl.searchParams.get('input')) || 1; + const value = await increment(input); + return new Response(null, { headers: { data: JSON.stringify({ input, value }) } }); + } + `, + }, + } +} + +describe('edge api endpoints can use wasm files', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/add.js': ` + import { increment } from '../../src/add.js' + export default async (request) => { + const input = Number(request.nextUrl.searchParams.get('input')) || 1; + const value = await increment(input); + return new Response(null, { headers: { data: JSON.stringify({ input, value }) } }); + } + export const config = { runtime: 'experimental-edge' }; + `, + 'src/add.wasm': new FileRef(path.join(__dirname, './add.wasm')), + 'src/add.js': ` + import wasm from './add.wasm?module' + const instance$ = WebAssembly.instantiate(wasm); + + export async function increment(a) { + const { exports } = await instance$; + return exports.add_one(a); + } + `, + }, + }) + }) + afterAll(() => next.destroy()) + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/api/add', { input: 10 }) + expect(extractJSON(response)).toEqual({ + input: 10, + value: 11, + }) + }) +}) + +describe('middleware can use wasm files', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(extractJSON(response)).toEqual({ + input: 1, + value: 2, + }) + }) + + it('can be called twice', async () => { + const response = await fetchViaHTTP(next.url, '/', { input: 2 }) + expect(extractJSON(response)).toEqual({ + input: 2, + value: 3, + }) + }) + + if (!(global as any).isNextDeploy) { + it('lists the necessary wasm bindings in the manifest', async () => { + const manifestPath = path.join( + next.testDir, + '.next/server/middleware-manifest.json' + ) + const manifest = await fs.readJSON(manifestPath) + expect(manifest.middleware['/']).toMatchObject({ + wasm: [ + { + filePath: + 'server/edge-chunks/wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d.wasm', + name: 'wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d', + }, + ], + }) + }) + } +}) + +describe('middleware can use wasm files with the experimental modes on', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + config.files['next.config.js'] = ` + module.exports = { + webpack(config) { + config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' + + // Since Webpack 5 doesn't enable WebAssembly by default, we should do it manually + config.experiments = { ...config.experiments, asyncWebAssembly: true } + + return config + }, + } + ` + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(extractJSON(response)).toEqual({ + input: 1, + value: 2, + }) + }) +}) diff --git a/test/e2e/edge-compiler-can-import-blob-assets/app/pages/api/edge.js b/test/e2e/edge-compiler-can-import-blob-assets/app/pages/api/edge.js new file mode 100644 index 0000000000..49da52df58 --- /dev/null +++ b/test/e2e/edge-compiler-can-import-blob-assets/app/pages/api/edge.js @@ -0,0 +1,54 @@ +export const config = { runtime: 'experimental-edge' } + +/** + * @param {import('next/server').NextRequest} req + */ +export default async (req) => { + const handlerName = req.nextUrl.searchParams.get('handler') + const handler = handlers.get(handlerName) || defaultHandler + return handler() +} + +/** + * @type {Map Promise>} + */ +const handlers = new Map([ + [ + 'text-file', + async () => { + const url = new URL('../../src/text-file.txt', import.meta.url) + return fetch(url) + }, + ], + [ + 'image-file', + async () => { + const url = new URL('../../src/vercel.png', import.meta.url) + return fetch(url) + }, + ], + [ + 'from-node-module', + async () => { + const url = new URL('my-pkg/hello/world.json', import.meta.url) + return fetch(url) + }, + ], + [ + 'remote-full', + async () => { + const url = new URL('https://example.vercel.sh') + return fetch(url) + }, + ], + [ + 'remote-with-base', + async () => { + const url = new URL('/', 'https://example.vercel.sh') + return fetch(url) + }, + ], +]) + +const defaultHandler = async () => + new Response('Invalid handler', { status: 400 }) diff --git a/test/e2e/edge-compiler-can-import-blob-assets/app/src/text-file.txt b/test/e2e/edge-compiler-can-import-blob-assets/app/src/text-file.txt new file mode 100644 index 0000000000..37607fa1b1 --- /dev/null +++ b/test/e2e/edge-compiler-can-import-blob-assets/app/src/text-file.txt @@ -0,0 +1 @@ +Hello, from text-file.txt! diff --git a/test/e2e/edge-compiler-can-import-blob-assets/app/src/vercel.png b/test/e2e/edge-compiler-can-import-blob-assets/app/src/vercel.png new file mode 100644 index 0000000000000000000000000000000000000000..cb137a989e5ffe5fe01eee70a4ad7a6e1798094a GIT binary patch literal 30079 zcmeEu^;?u(_wSHWf(i)IJPJscbPNiDl1d{jCEYPFFd$(P5<`PU2uKP_mr?_a5(3f^ zBON0QFbteMyzloq=lcEu=cflRoZ*gruf5jVpOyQ5c28gHJPjKS1OhpK_l|}k1VX7s z{H3A}s=X%rzu$=` zjme+JK_CHN?`qsM4k1~eUQ6RxdQ&(5Q2g|>52>a;WS;ybRomrjuhk)R>X7IRh=>nF zaeG!%U#*%^EeRrZF_Li`9@=F)L8^00dU(`&Yj~{oXPIR6*v#9al0tm*Y$v+_T9^1B z2t-By#GCZ&+x4rFXP=Nj@(X8QUME2Qb7thC+S!3fMN+D>FZZ;Fa{@oBR6J*&YMdm* z=|LdH{|@pmPyW>t$iHjnUnuw&9wCr_q2OOA_}41{j?uqf!M{-OFBJR>1^+?;;K097 z@GlhnZz#wBdhzJr?7_c&!oT3~FF5>fZ~)lwZ${<+XGWz>c&Z8Jf0q930-)UVm4L?? zw4UsLZq);_fV7#<$itjT=d@0C5)ry)6*ek_lb@E}dIovQnPS!rH_o!d>he{jw>AU=Sc4x638JU{^8Sia;D4tyL6ZLk_m*SKj;~o@zT9C#4;CpEz0#M4yDb z3Z4xQ4n02DIZL6JJr}KvBNX{P=k$i+FitZVf5v8vmO&Mf$?-H#oOsv{=MhD*9v_nk*d%sY?H@}!Ct;DZ z6Gi$Fk|gWT07FH9WKVJaJV)*A?c_R#^G~?-D{3w_6ZR*EYu(5A$K%S5fSk4pFNW{- z3IK5_i#yO?nKZ9i8>>*_PBQ_V8>fY(_yA3CWe18A&_Hu-)?UzJ;KpyH`zAx((#}Hn zaM)I}T>PK?jY(ql;nZPc%XcqKQ8&IJl{nmgqE;zgU6<}XP(=pTbAk8_Za7k@4tWp%Ev`II9gy=rP`dbcXoqbdjv zSC0SFmnK{k#t(`;XJdP&*F;KpPKyL1Qx7CpJ$9zp-Jwm1JC19mP{N8qXZgti%JxKx zNQnZVM3~I3G0!OiyF4%~&0r@uLFqn1w!9l0jHtW|+jODrxH$dAcOk%C2~Z&m$}Vn8 zb69IM>EP)p8hQ$U%oh(hw$n*yFLd1NF19QMXWzYh2bNjnz$LXvZE6Ph2^)c$R|9@7 zcP&VyR_jUXp_)_WPxpF}Pu_WzBJpFZ48&_0!L{y+k;*%rQGY~4h4VC(FR0+O>Yhix z=vhs?&!CD1|C{*&lFM=-q$9l|b59t!_1%+OQ^38Bjzpus z5eHqO;!(@3-6Vmo&isA3FgIs6?inz}oijFEWbi~qXZcsHd#9J}IS5386!13r=1A=z z=^!2MKCZ}xZh~}eFjr~#sUJfALT*hKgDQ96UN`sYK{s(?#Bt;4BA3*bWKas{=`~#L zX5Pdz*F%@`5)KI^j&z_;s1xrtYTF=+tc zDLki?~1w7nZ(MS$g> zB%OXm>vwl?shXen7idV@SB7)>Kx?-B0a84=ntx@kj0`EeE30H0wJk+l;?qK&8VU!; zfK@~yCwU_w>ZqvS@A6oZu55ydyW%#dvo%xk53Vg1RqvUUzxcZK!*TDwPu2lXP9qrt zIA(AaHH-KfrQG$3><3onZU1gCV!KHh$E_S95#Mvhs7ZlHNGF?0ga&2sM^by&cjcs4 zU@w$p#Y>?xVDY(Xq>J0;(bLltE0=Y|aZ@+mY35QK8&NB~^IG`EDzye5A^TB7^EK)Z z(M~*3IZ!Tz{?`mtT9Z;qysslm<~-;h(&UP)rHKLH6QQW8nx~lxff0`Oh%(&(T%CWZ zNPA!+_1}f_ic0G36gvaAYRR(@`zr;3u!S37W#XD%ecSp8uh1^WY@)!AJJSyj$OALv>*@{uO*n+ z%ln_z6kY%^s@8l8!)>02@igEaTESB6=0*Tq*j++(=G6lUf7bw9ONvHo+GnY2i z0+6W|D+&yb8nDY>p!@0(Ki_e|#UcQI%c3EqboR5(;n_=rLxAUTRuq>u6C;2Y5cTRu zny^_Rz)k~j<$4~3w9E$EjuB6}vPmTOD_E%-=;kVM;g02OOA`N-k2WN;klpSyM#+?pE%o zc;);1XrVjDK8U}WW{8*9&*gJ-auU6OvL|5BpFK5^a(tz>{r^Q67)3pFUnZ#ztgWl6 zxSG9DU=kng0YS9&0VCt)L|Yu5e&8DtBHx}|J>>S4?1w)hME~!9P{!b(r_O^o@Cz`o zp)-jR`SEPiWf9NC_Q@&s%|!Z%ArAm9TGQHq%+~;j%7%)=+Qh%DwVyF+x}{s_!;VV$ zQNXygwI7)NWDgM2C!krQy>_kP1cZq66pou(05& zLCt-p+vZR}Kx{v$tUrElQSC3h-S84r)0zU1Nd%d5`LoSo!N7&HBLH~8tWP6?&hW~0 zaU;Bp)S|V>AU`kf@WxwqIj{`=4NiM>B78MdTDdEEWIv|4*HR<#bM4Wt z8fSE}0(q{d9{5Jo(L1o85oliY|L_Q=C@)Wp{#k)Ot?SYYaRpx9e&C2~+~_nnbZ$%x z9|3q{WwS>v2u{t?K zBTg{+*zgl{jTxZ{Yn9o#3NP0_IUH*}+55Yk?VT5a&c~q3t?H#nL|h}{mL`x}05ql% zdG=`NLf+|YOFtI<3Ddgl@@vw&mGBM`hs{E8qsP{9v%oEEJbXL0e(Zvk$(rrOZaIvJ zNW7KPuf8JPc6sUi#AvA*Mbi1c?BV|MIKF1=pi&WcwPp%_p*KZ9wQ}QLoRh$II0ZmA=fiq1dvrHR?|}>2hC0+Tawn{Mkcp^ z|BAX;4-FFVmSMc8sf-mAzSwfKT6)_5(gTUdXM49O5L!@CX*F5rYB`^V_{l3NY=mpM5Ce1Ca+?`T3=QE1?*u5wl&=%4npd}9|@UElk>@( z{<`e+Q_tBVJG?XblA5NnxiapyX%s8Rbc=ZFvER|z@{)N{bF6x@W_x;+e-n9;(#%GS z(sp<63G{gWZ}jKcXO*_g?F)1q7=kwHITq%laC0%Z6`zZfzPQXxzWH4=E!yryJ#}RAAYeA1gGE zCGyQrH$Knm>n%(BW>K0)=&QmpGjetzz6!0PmK9|t5iLXoWK%{I4q|bu*oxEk_NX@8 zsS0<1R?d50t}D8E`JR->?i9RI{K ziFemInlJolrKm`H0eCs%<>W;~_vKyhxzESb?36~BKd+*Y;jDJBG=(_Fz^73QHN9G0 zbaiE5&caO6b5hS?KZEP?P&6|z+tYldGWp$?+OMZE#kgb0Zi_}@dT(IT-FvV)>gKhj zEJB4s+_goT5Ep^SwoHHq`;M|7qtWTe3?z9>j8Rf~(#C_nS+w*G+u{SlF&1smx`o$> zVd?c3VJs~hfrpb)Om}%4TxzRT%ylCTBLK>xnu zzAiny?%O#XwnMeQW%@n{5rGQt!4uZ|s_?|iYW+`Jz8uf=-~2d{&`CFP>O|4AmU&JE zt0;Q-U;{;y1P?k`uR(jv2j9G6b+VXZpgdV;7eTq$5IB%$Yf8VERr`4ETNxxu8bo5| zHpoxUxhK=cl)PK)?;hGoy*=y{MD?hxocRyeYZwv}5(@7-M#@CDIBVEWDSvmP6mpR{ z)$a|@V5Wi1u0=FuX#9m~hS*q=^|GXxXytjLvdjdR0tcnrWWpq|c=pm8QF<5EB5yD0 z)yauAzKY`D_5+~0_p+h;6W&4L%A+g3>@EGK`0Y-HCmFoLa1nPd5oKlNzd3USe#j@H z7}+)aRx9*8x5dpr*v@5Y-fRJI$qbh;njCFD_YY9g`xCf~>5SPVuu3m!PH79_v?w9t zWo0MwXa3#xtDF5YsIo`%4q>}+l)L9QR&+L*^P?rD zRPdie(Kw*YqA3@5!+4$;gnbMW-g;I|W6(rqL!|qk=*=0{a&^_RF#n!pkQ%=%# zCjZU05LU!?Pyl2zwCy!AH?knrm$4SyFqQM4%4{jEtC^o$tg9@CeZMT1osR!jk|=r2 znD6mA-sMTmcdHuyt7WswlJg6R&^(9@Fcz|IixzDg)d7=FV8~Qvo_AbN^YB+E?R|r; zjIiz0W;3YTqE-{>*-FZf2D#=m~wG{Bo@~tWFBqSylUF3t8fw1@Rwf{vg zD+|JE6Vi9U#qi+J6IrR$lCx81fYW?zt&0Bj!_Eq}Xr8^5`{Feb)b^81 zL~zGp#Dr%vu}0@i#~OpT^xcyz3hSCylaidew#_g3GPY7|51^iBO)+$A9_%}*!A@|0 zZG7O^2~krQQljOc3j34T`2>!vEA)&V?7PfaAx=i|ZZN4|^~XfDXZmuKS&eREzx2%c zqjFoyo-%#&F1Ts}>-f8HHP{R(T4s`Ot15?dT|3gF;eY?ep&^ z>|murpsPH0Oc5Nw^=@>WtDRJSF`oHoO=dRQIn>)GLM}u1yD(Xw=f!L7VrY*skD?sf zhXtZMkq{OniMq6TcXEEZT$G4l&04TZ=Sh>)^^KE|+L)31=YoTqZ}qmVMcld8&r|uj zLDm0twYCdZN@7#`W@{l;G7dg?XI;+w{t7{5U7Upciv)K-mjg1s=26>=u2(m4ytT-Ygz)=e%`3Is;Q%#;jUANCIB1HlH9rN zu_IB~AF@lC#OH)06i4&cL;JrYsyvt_SKBeb&0HBOaAjGp;Z+XC87v?ruWBdtMSg_T z!+v*1^l7BuzxvcY2s*I!t&BX100K9bhd=wrMGl=^Gd){NNiZ*@><(LZ+jq`l@yAnp zYhy*$5^Fii1|CmpEnz`Uzv;7`cwnu3k6-Rb2&PzB)!=o&I6%`dva2F?C&JQG#Ei)^ z?#w_ah(ak*5$pfgJ1n&BDZ|3Q%dWv&mlS*6Uo3SFz8d|BQd!R6m`C%$5)xg(zx`^X za(9MqoST%&N9+njTB#wL7^#NM5uI7pYuUW}-kFket7>EM>~cYV+x4QsAu}8|tr#ZL zT*ipVUuALS&Shvud}W_#^o%M_0<|Y`q#)b224VyM-g8`~P{pSf9-YKA0%E*wd6w++ zJ5_-hICfy=&!0!XVm<0pZr;jpQo7;N?tyM1)#;fLA?rOJ;RqRP@RQ}eVR^p;t2xzi zUY?tTT)qbW=yR-k`g*LpPb42OWFTo*cyZ}u9M|4Q_vq!(6k6d%qdCDo(bvW~fhS^1 zOq|5h;};mNslRE}*Aths-CrvIj&>h}mpPf?MtH{yUF$5!Ah2|KK`2V7LZh3(Fb(1T zOQ`hV>Z9vqF%f3FLiL&+%~fShU6i%5?l$)wcrw#1Ea$ABCc6P_&Vj{&il55FmO02b@y+W0Hs5##>ONe_=EC4*+F=UBOItEy+p zO|!v=Gt#q*(E~NsYs-9bF4c5R2{Pm}IVKLm+BWHFncwvwBsSSMb0eZ`JFnOtul}fDvq>6-(l~6T z6k{mxtK30|=^XJxoZFP{Yvp@l1U5Lxec$=gCW;`rOSfv}FlpnO+I~5@Jg2)y_~Xy( z=s&Hege}Hw-XUL``k460B? z#jHA4Rn~C`FJ_6Oe4v-kX3IiOz<02=7q;=ww)0RP7{zRmtcv;a-@|X@+tTarnh;VK zQkt<^#4srv?ZUbsSxuIC z6b3zOhW!-15{8v-g<+RxbRYM%ZG)AipYPdJ9ZaV{2{CZ7_OBKe^tmSwYH9U4SF*kUxVj5PyAfsyO_YtFLj&)COwJlGPIpvgt z<%)zuXMhV{JhrH83(JFpe7;gMCQljW=jY?&v+I!*SUk2_W{d9l+B=kYyZwz#3f88k z-o|er;*@|BXiRc3m|;|=6;p2x!W@QzLk2(pSXi0wt=0X+Nv+o+z@iYYZM}lqTdknu zZ%f{t_Z-3Kp3Hbkg-EQT9oVCVp|$%AuUQVW&QVIsf0oV6soHNpcmcO=4&8I~)THJi zMrs|*_i=c06e9k4Ff{U+rw#;X7 z=j>qt(bmb*g@Cz&6Yj9!uxsBk3(m{q;t3V6$VwT=Oe-m0{*knUT#>jWrS=g?LRUrt zFC!uCg2eocyzQqJN%oH;Nl(;DdSh+9(1q!MKr_=N1(@|kW)uMZ?I0^2A(O4aAY-? zs@DgvTRQ^#Q!OBlM61wzS|wTxN*r0&ur zq)pZhs=U=z-bG@;%PXLsqkdZfDkXD&y7WoMd<|NaT}T@@e{ynCsrO_+krYdaXXN%@ z6H|%jAe+dkHNv?zZ0XsR3ydwm`^Im+pcm`cE;jXeCXY@Bag^=+PJNnLjxmC zR*IW~Hhw{jw0RrdF7AW6NO6hZ4v+ZgBp~s5xBa5^^jL`x7VB7xi?Tg2#P+dfh zZ6UOTz374>`5SLGr8P{o+%W5n4ZjC()YWf&c8)P&5x4R9k?~rPWR)=zQn7O5R=@K^ zYTAc*_Eb~j!{g$H3BW7E#hE&KTrX3Z;^haX1)1+cai1US*ec-sJQDI1a$ZbuD35*a zGj!aL+Vjm}LK)qg*~1#15HeYRIoUUY$B;hesF}{XXc`|+{n=}pO$b$!A8cQ|W?RkJ z1CclmGBOxTg~TkpUl6ellqhJLGHaqQm`ldKGrX_x?L{K=B&7Jdk?U*(exp(k=5Ye* zUxxb>^0%KV1TuK5KX1tVUZQyS)oXA1Z&j<^Cv{5^N!ayh237f^dbWU^fW#gqolV5y z;rc|)JNJMH=Vu+G)ae{76D$u?VoyXW{({(zo_Ps>$G3K2q}#VEGGLO|b%xo0D_6b8 z_%I`0_wO~hHim%@#k&!ky^Y9cZ{UgkGU+ZX=DzYAq{kJ&2y<{*v%=x} z2UGR_mepsrP2{o)c?&c~FcG zR}&2j+@LZKL8BIV74|F(t2g4Jnole)rZi7=7O(1f7GSS_`=mX)vFgGS8_i@{LaC`k z57kUlPhz^{*RpwpXN+t*>BYTrDCw-1!9DzygN|3okRtE(AK-YgC-@e<5u&`|(mUT> z>_*j zA{I6Z(>osS@uO4lvWYcUm#MJ%6-O-k*x{;;2fzaVv3vD!@v#1=K5e1&cdaNU0QvC2 zP;x|frrX^fjx~!oPcM88=j9o0LDVOc=`F96U6J(gDIu}bwC3azv3Uv|eo#!&O5{qj zX3w7PS+2K8WGhU5Y&dEsE3WBpE6}>CIpc6Kj74ZOT%x?a>F>5f>ekhHw{J2Zwq}}@ zDwPC>;QsnK3GHIOb~F}$o)j}drD}b+5HOsh-lF@} z#Qk?qu+q0w{Xpv2-7>>xA9RcFLP%-NYH2g3QM5G=>5SND4VagvGTejB6?;qd5sq2* zEW6<*jWc{|$_Bh;H6pa8AJ;xTq%yOn@duq4uIzA)Zwqc_C?3-!{33{f#^_tRF&{xk zgzNsWo`Q2t=uN&tNdu%ynj6Y!IrF=e`Mnnq^2=p@EE3oxnI|g8k_AmnCf07(Zr9Z3 za@TLV+(uTpx9=z;3 zluUoEpu=U_yy>D)WX;@M5_reOuyB}iz=*B$t&m8x%tt1{^iR5awF`nMMZs(NGrlC= zGy^cM47@70w~^;zsT(fC8nt8YDky@oyD19R$8~n|8bR20O)8ra>Jj`~!hR07fBlT9 zia~I5j#0t(!VT-zCb3zIg5F{`_gS-~f0jUxLc*rBPBPvoZOt-5Yjl5OqSp7J^NmgH z4i*hfpY*mjDJaLoXZex(-c~fFL)ex$!(w&7wbXKM@$Vjn4O5;^2UB-REo!mSW0ThT z+-Y*EvrY&1T@Ool8Vx-xX3cEFO$z4>KRmK-|5BvEUxX|)*IQQkDTcZ`_N>dOl!g%ID zStoy^A@W|qoeY2VswEF0W~*E6PSRc54$pMDOde?e8MsTSFFtF%d1{ReD)rkKPV!Mr z%er(U?G`8CQU=q!2YireD9wp3LogmC2LQyujQwrVLe1*m?vIwA8D;Ej2}Bz~QDLjTrMEd6oPr^^#4&T0xUSsGp}a;ihp`4^w20ZJw*p`hX+%g)_n3 zucys;Q2n#=b-ZAFcBtv60Ukw01hbq@-teypW*fJ_JKp*j)@Zwy5@$_B$a4Nt{B(2W97Vu0f(=duX{m)EjC*p_nqm@Nr!U=hkFa-}qA3 z8b&g`w#Hwy^uqFL(o7DM6|r^eynoTcUk@pZy0!4w<+mN=ufmyFm$J;9dh3L4<7sx` zM)n^%l!YIVvMn0`HA@n+)8VVsOXN}+I^X&JQiQQ}$2CmX2u7=efjvLpAnoA`(s^f4 zdN0uUvRdz@qF~lS*tfKwKP~Y&HlE&k;R<33@y@BGqvSBE>{n|+X>eLt?P(5GrFf0*BNH-ED6d$0e89Km;9P9vz=!X> zt{-~$7JvQDT$EF2!9=`Vdymm$W-GO63Xxpj*f{T@pB84FcV*haZibxayVaiw&os$ob}N zy$4+Nl88ib!jE8`O2yCasB)9*BP;Kn(}Jm&^*aTL%E`tUQRxn|zniY4`#+wqY_o>_ zXaUR68919{>=yJlbDd9Adj4yX@%%4-$h_WWukln(7(_+qYIa5pxS=EYP6#dojQPW)?bp^krltlTIFBMH+P&xG&jYnfz?3*Fvt9?wb z^**f;i=32^Zpum>P1I_ysjc^ntG&{d%G?o%Ru7W(8)H~3vP(4C`suW zYA4{jQ9Shpi0n-W>3P5{=lY@fh+ksC@-Jez{qv>d2VM8~dg4}0ilAJoaQXh+Fd-9p z)6+jG2C7#=8bK66=kG!-0R<$*P za0HQHo(GKv-j^cP>;-uy4|4j3zIoBa2s)820za4-h0V}?sadoiU5og+Bu6081^y4D zEgMji5&!;!*!+`)j1SW-;f;%Ne-xnA##1FFI$X|2?^bWn-&5abJ;LUnDuXr#ArdO4 zG_g70A)dcyo#4d&R=Pl!)6pRQxu4fdjMh>-x-^y>2z3TBVkEgv`C8Rz@Qn7pE=<7h z5N9_$9`9tU>-Qg1m*jLXEracaY* z6WC$vK%ptgeSMY)*|I3~UT~XQpB?YheQ&b<+>W_5ru$l`z>hA&e|De7pS|nS?En<2Cm-b(fVR zC3$&PJ8i-Ku*^JDnwn8rRa+;<0HF(!m12xM#*3NpG#o`|toseRSQHm5VgSoIJ&)*& zdTE~vCanaFA6T()GheUsx$MtyGfy>Kv8}{F_0Tc0emE6ET#}?i)PbPykp$VXGdQx3 z!!;M{USrR<1>Ic5{Z%(9o#v zz$rp94P`aZPX<0T?HpzU2v*Xm^}S5YBuThTP!T{Tp2O}h=zu#f;gaQScS6o1;orf4 z6qp6M=&pkE?Ru=(fgW=;Yj z&TAlrMJ8fB7~P%7%^;D+w7E6gS~2cNFQ^}ob@Eh*BAux{kV`CaR>8%DcWQtj&--)` zp;^CdGS1GA(4YTXeGTGk5C^oOO}Bux2+J9f9n;sXBVpA0!tGWP0RO1HrEgoH|3zI6 zgaj-1ZnhwJC4C-H2m8-v`~S%gS3AMCo=(B z#0GIlgah024^CG^rY&7k(g_K()-*B9g=&Uhb??ymJYX)L;FrE)Vu6%up9T#tP>2K+ zn6oz?#~w#y5$V|WxIelBaevly?*Bg8TT?ef`kFy&m}#06^sV1}wD{n*!5J^|4QdB&8_k>;3|don-o?XWgAnXv5i zrIj!MPs?P2MM1R>>|NyxgFt~e+bnNLi#t4kDKa1kG#$SorC_n+Olw%t`z9?#LE2_P zt!AfL{c|Jw90TRth7_lh0(oJoYux&|j^3f0Q!2+#eQV}un5-g)ORBr?cTAriRh+W% zpgBzn17!Fh{fR)mG=i0yCP6g}LiBCqD0b;Bp8qBSG;)rJa1?@H;D<};NE&keg1*`T z=RcU?mBv!3v@5Q6zK>H$a07y=+4DPB{`fh&QA0hRMl=Q3tHKX_%pV_HpAtPd6J^B7 z@gErJ+_m~T0+0{>Ui%9c@i>qqcD4(r?vfr2-S|~|c`GyXK}q$Nc?hU$WBcK63^dOKCH0>~x~rv9GGu^D8s9@O=u4znr~rbq=Hi3~c)+gEw-Fs~>Q zK=VV)*S2SVo~56P9^cvx-OVKsivt@7loICK3l@~vKx%IR=%1=k=i5IZ&p1aywLU(c zNmeFcWJCkk6^wmTur?A+2s*^u$_7bc{$R^;@h$hHJ0L2keez`wzfAyHTLzq5`<4j+spE&Sg7@=Z!I zueuk?REE5N^>6&>*VnDyCR8Ve%CTVxCO8HQPncy`9kSaiNP-2C z0Hz6?%jdX$-Q5EP^6+CQrvI7JVqDIX4BX*l%rtcb` z&)4yt%!uJ578?~xeGy6Zw)kDET~MSs&kR7-0Q*zW$<5i@d+U+5AT$@-2BT85JGZL_ z%X?xp7bs|IVU`XI_uGd%VT^`!7Dwc|#_OqE)Hn47ZhsgJ#14P7^o@s`Yu!3=!+rLB z20bs)s`Ki=8Xu8k8tZQRt+`(FP$l!B;zWvofer2NUFoeblfu-i%$)yr{W4nRL$mow!d5C;azEHz=uoj<>9ZHg4 zTt5kl@00O(emYwpG-EbJ7jDA;qJ{&R)yB;ispLiDnqxSW0V&ggkXjZIv{`O{-!r` z#opuTm5#(8IZ9hKz?UtI>G3m1K7d=#V0LqxgjxH1^Btski?$A^$XTUG?|HBOoKj~?nKOOdyeQfVC&*=3KJk&mINZ_X10+k970L098eoJhX{iy*ot8*CD&KIOS;?DMSZ&k&50P<4?n7O7`I zv#MI~f_~mh_TDC&c0!7~e`b(P^Zv#zdZa***OZ-6QQRmKd&B;A9#ocqv&#KW&I-_+ z7P$S{N|l^O%DSB;9<130TC%FpTqY~Ua09^x$LPNaxhaBix+ae-3h=3`4Ka<=`>PhY zR@n>KjRVZ&C~o?rAg^C*AK^XdPva7v9LIs}DUUBOW~qU-KpVqdX^qTrU{}FeBGV2d zeEs2rN5}N(O_(d&>?BmWB61l^Spz5=Fg4B5O_Awz)|1qm#v`@6-LCOXVWs zLe$W8g0Jk#i1md( zvXih9Z#Kn>4*m2AK(n>B;&Zc?clC3et`_A<>4l^h#uThmWH`uodnGI!PmMBpZ+0sr z_#2c<$z*Mbp}nY+O8`Fo0#-0hxo!D&|KD@#rd(dbZ_XQO2je&zr}TwmpOx!qd8l(0 z4U1YIvdWiQIWRt65U$^++R6lb=f7tk!w-%&YnK=TJeZvNI zDQ@wBtjU*b_X*a}_vTHhw+OODy569g3pzXGIxl^kdErlIaIp@PG2oly5aOXx4Ck-~ z<ZylPIEf?Kx6GL;*C~B(6NnhU_Ol+QtfrQHYj&koe?3&7 z2xfh?c74DLKe}d7Zh1%yP_)LX2FI#{C-(ijl93&u={pAl!O)rX?%B=*|2=aX&lxL~ zIDgTeaF}v}Qpt@sg8w0s26nxN7AnC2D@I2*4&-JUKKEu;a-xXIu30QM8Z5u{<2Iz) zs@y$iOe{~jVauWOM#&9+u-rVKoO(lKz3#3>s#C1gq1Xr#1;n%t?Zl;@v`|?A6dP>9 z!P){3#XKy0!n~HjJ|;8hV1~W|3J)y>Di)TT?XKEAs#5$7qTjANeR@K7bJN+M+U}Q&ow5`4f@l&yhASdNjzaM+YW|eW94*odfn3EPMZ%uU{tzh! zSr&CKTbr}jUYW_mhprq-3sLI2PmQw;{=-?6dF^ri9bN?^o=mQol*J>NBK}BSXJgltO&K7wO;?AE%`+mKpL>YKzS zYWy>vW%VK{e;37P3;p5sg2a2txwGP416R;P?Wa7&YR@HeVv;A5F2}OS{`sYb@j79O zH%zpmxowS9(Bgk$7nvIDO=8b?S6DYLk1rWlr@E$v6C#5_sG~sKa2ay+cLg$%>@S!7 zw$pf-RrXq{J-(}>?n2Chyk>Eis#aMa_IBC=FYnX47xa_YEcrvCfNSfc-_Y|$3?puo zJ?v)$%dFx4wt3yhpQD?SIUdhJeY5zBZc;(Ke8M(aC~z?q-iNqxlN1Xx^6YQfYg>@I zi!ESn{stSo$(B>#FV>~HHR|-(Z{6ALo1b~vr3f_;Q_H_2=SE(NIvOHDs@${_vS0`s z%eI?5fU9;r#!ZdgbqSr&eQOGM`Kl6zqcdxLzs_+jRRM6MrBvd)Cz?1JB z_=3b2N#Q3{M-oUE3Gm*b5u=Gi@g2Wr#C^FUs)S9XT&EDY zo!)TC8Egd!)`}{Uc)`bxi@&h`Q%Y>|^Iw&cuJC_uq>ac@!tzS(>Vbf?I~dFE_^JYi z(PJkkEdy^7UoGGcu^6os$?_d+x`V)2zc*v> zTnq>fn#ExlkXgj^KsUUt@ng`iWrDNnZe!1gR#xPV`#By{8k5+c%JGc42~Kut5b_yb zigUShmCDfL?am+P3X{{T^@7`%V-s7d-|Ua8Y!pOrRZp2HK4yMkr?Mc0csxDLhCAxn zy2TS1>frF$X?oc~8M(1X(i9IN*H4=3w1HGw6{=-CXrPj-?bmp|VAFwTZ!8jhQETb& z`O{MQ9NcfKuinw3Yy-9UfYd51Jjc55{4OWWZjg(|bw3d@0bsnT4ecZdBq%;|C-$L_ zK{iIZDH7feI9L8#AWYX@|a1gIv;N9Q_Ef&ucV&9Mr? ze;si2pypBO;^#87pBH09-xt0Zro~B7->OsJCK>iu$wv0pu2A196gYqk-hz;JSMDfW zpoZ#3Nz^u7&)=i)TF-Hs=<2YiAr+*%-sBNCM^jG@m$cC$1$zdC4rV~YN@)&+g&|JCNE4v)fDUHJ*QVr3rhFqAMu9i>9?kFroZiT?k=eYWbuvdl8Qp+mHl$~SPwxd zo&~#}vKIV2R`3Epm7fZwz6Uk+<0IM`_cpR*xk z=V9q4jh!Nr>!BO0O7Hq<9m`acvh(z#@j{LlFn#SEzHZxy!pbkDo2xNw3M^f}`_6I0 zef}ExQiP;eB`H5~w@u)!9g+*@fQ;5$D?K1`pyV=eb}p zd_YV*wz*D-q<@%hf?*%^*2*pAIpu~eJFaW$?eK+ZIAVv6IcOyv>?KbL?|=8Zg-Bl3 z?|OX|Ivcj7a+}#sD*FpEe(dAusj|)Ft~AAO$!&9ji#=nPbL=rk;`C^w_ujaQ;9sRp z`N8l5@Zto-hWyVE2ovyO@1$=S%AS(TUY*yvbpgobo^nE`Sudkj8xLTeh%w4X#7ajjt(J~Gkwghvel_zv@a6<6XkjV;sSdXVC zpceX$xO)+@o=SZIxdb*dleHP!w7?b4lCI#m9S+Qb71UujMhDWE{Y!9A zMFs6~ACJIRphRRxHLoH#)ux{TdDH3v7dwX3iH_wq25*6#w}7ZP>I)BaNTk3M)^8O; z^PmVX8lr^k@Vb%T&yt^&!s50q99~2t4Sp)a`PCi?gUmjtpF_rLiP`LQF>5yBsqrkZ z96g=RsP#_*8N{;}Oj`m*1wglQ3#?M}nVW$M1 zjt05?nEx~R18<(}DgiZ=fLa>f8ZbI(YZ|1z=Wnn)^~HUegAp%qQ!K-QB|zn;fz#~l z4LhrVL&3mxP6i+=3~Yf8x&v!O8l(fm*y@%D=nRgA%t)qgvrS*7?zL~=0&Q=%sryp_ z(vn~_?ZBdT;AtAblxbwd2rQ|=s%J7Rw2_|AEDCJrJU>5Q{x3L)9{V%|+y_lSG-Pd3 z+@%SMvWAzyP~MVLRrm4HQN{(Ud>U(jUIxdQbShKJEpP;f0GAAI1$*bLa6`Z~U~0>) z;hQoYI41`ZOxUc`=<3O7_wCy^76k|HNG5GaV%uN{bl9>S8J#~2yH0#P(*{0#WH!*y zq9BbwFBbQ+HTeDevfN(QxABQO&=`j2X-q9ud3SagGL(D%h9F*}i|B?vg_ z1YE<+*02t^=GQ1@M?oS;$AcI?R)r~(maYe;+>$cn)b>1yQ|Hx*FYO_<`+$3{4&BMIc+|%;XfvI&thdFw!-P_U;Du?LayX+?>gf_$`Rz z%sqJq?@2HFfgw4Y3FK!BX<>yk_dz4=8%%(St>iDr3=4LkU=(n&5wyZ`LrA~8y&Nc_ zKn87?225$2r-5Cn6M3k=MFAwb=BQA_nd`u9*1*jB=r1rm9R>~kgJiA&lUk(LheBYa zEH?eXIPD$qI2swC*P*d{*_iQ=4e)3V=O2t8_6u==wpxR9J^13&U|_BJB^5YPQaAl& zzfWU|6e!*s%8eKg{Q<31VA!J>F { + let next: NextInstance + + // TODO: remove after this is supported for deploy + if ((global as any).isNextDeploy) { + it('should skip for deploy for now', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, './app')), + }) + }) + afterAll(() => next.destroy()) + + it('allows to fetch a remote URL', async () => { + const response = await fetchViaHTTP(next.url, '/api/edge', { + handler: 'remote-full', + }) + expect(await response.text()).toContain('Example Domain') + }) + + it('allows to fetch a remote URL with a path and basename', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/edge', + { + handler: 'remote-with-base', + }, + { + compress: true, + } + ) + expect(await response.text()).toContain('Example Domain') + }) + + it('allows to fetch text assets', async () => { + const html = await renderViaHTTP(next.url, '/api/edge', { + handler: 'text-file', + }) + expect(html).toContain('Hello, from text-file.txt!') + }) + + it('allows to fetch image assets', async () => { + const response = await fetchViaHTTP(next.url, '/api/edge', { + handler: 'image-file', + }) + const buffer: Buffer = await response.buffer() + const image = await fs.readFile( + path.join(__dirname, './app/src/vercel.png') + ) + expect(buffer.equals(image)).toBeTrue() + }) + + it('allows to assets from node_modules', async () => { + const response = await fetchViaHTTP(next.url, '/api/edge', { + handler: 'from-node-module', + }) + const json = await response.json() + expect(json).toEqual({ + 'i am': 'a node dependency', + }) + }) + + it('extracts all the assets from the bundle', async () => { + const manifestPath = path.join( + next.testDir, + '.next/server/middleware-manifest.json' + ) + const manifest: MiddlewareManifest = await readJson(manifestPath) + const orderedAssets = manifest.functions['/api/edge'].assets.sort( + (a, z) => { + return String(a.name).localeCompare(z.name) + } + ) + + expect(orderedAssets).toMatchObject([ + { + name: expect.stringMatching(/^text-file\.[0-9a-f]{16}\.txt$/), + filePath: expect.stringMatching( + /^server\/edge-chunks\/asset_text-file/ + ), + }, + { + name: expect.stringMatching(/^vercel\.[0-9a-f]{16}\.png$/), + filePath: expect.stringMatching(/^server\/edge-chunks\/asset_vercel/), + }, + { + name: expect.stringMatching(/^world\.[0-9a-f]{16}\.json/), + filePath: expect.stringMatching(/^server\/edge-chunks\/asset_world/), + }, + ]) + }) +}) diff --git a/test/e2e/edge-compiler-module-exports-preference/index.test.ts b/test/e2e/edge-compiler-module-exports-preference/index.test.ts new file mode 100644 index 0000000000..b7d2b6abd3 --- /dev/null +++ b/test/e2e/edge-compiler-module-exports-preference/index.test.ts @@ -0,0 +1,58 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Edge compiler module exports preference', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + import lib from 'my-lib'; + + export default (req) => { + return NextResponse.next({ + headers: { + 'x-imported': lib + } + }) + } + `, + 'my-lib/package.json': JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + main: 'index.js', + browser: 'browser.js', + }), + 'my-lib/index.js': `module.exports = "Node.js"`, + 'my-lib/browser.js': `module.exports = "Browser"`, + }, + packageJson: { + scripts: { + setup: `cp -r ./my-lib ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', + }, + }, + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('favors the browser export', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-imported': 'Browser', + }) + }) +}) diff --git a/test/e2e/edge-render-getserversideprops/app/next.config.js b/test/e2e/edge-render-getserversideprops/app/next.config.js new file mode 100644 index 0000000000..abad3c69e6 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/next.config.js @@ -0,0 +1,14 @@ +module.exports = { + rewrites() { + return [ + { + source: '/rewrite-me', + destination: '/', + }, + { + source: '/rewrite-me-dynamic', + destination: '/first', + }, + ] + }, +} diff --git a/test/e2e/edge-render-getserversideprops/app/pages/[id].js b/test/e2e/edge-render-getserversideprops/app/pages/[id].js new file mode 100644 index 0000000000..5d17ce2cb2 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/[id].js @@ -0,0 +1,23 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/[id]

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ req, params, query }) { + return { + props: { + url: req.url, + query, + params, + now: Date.now(), + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/app/pages/index.js b/test/e2e/edge-render-getserversideprops/app/pages/index.js new file mode 100644 index 0000000000..c2380c49cc --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/index.js @@ -0,0 +1,23 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/index

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ req, params, query }) { + return { + props: { + url: req.url, + query, + now: Date.now(), + params: params || null, + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/index.test.ts b/test/e2e/edge-render-getserversideprops/index.test.ts new file mode 100644 index 0000000000..c3ea23aca7 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/index.test.ts @@ -0,0 +1,129 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, normalizeRegEx, renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import { join } from 'path' +import escapeStringRegexp from 'escape-string-regexp' + +describe('edge-render-getserversideprops', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should have correct query/params on index', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + expect(props.url).toBe('/') + }) + + it('should have correct query/params on /[id]', async () => { + const html = await renderViaHTTP(next.url, '/123', { hello: 'world' }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: '123', hello: 'world' }) + expect(props.params).toEqual({ id: '123' }) + expect(props.url).toBe('/123?hello=world') + }) + + it('should have correct query/params on rewrite', async () => { + const html = await renderViaHTTP(next.url, '/rewrite-me', { + hello: 'world', + }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ hello: 'world' }) + expect(props.params).toEqual(null) + expect(props.url).toBe('/rewrite-me?hello=world') + }) + + it('should have correct query/params on dynamic rewrite', async () => { + const html = await renderViaHTTP(next.url, '/rewrite-me-dynamic', { + hello: 'world', + }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: 'first', hello: 'world' }) + expect(props.params).toEqual({ id: 'first' }) + expect(props.url).toBe('/rewrite-me-dynamic?hello=world') + }) + + it('should respond to _next/data for index correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should respond to _next/data for [id] correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/321.json`, + { hello: 'world' }, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({ id: '321', hello: 'world' }) + expect(props.params).toEqual({ id: '321' }) + }) + + if ((global as any).isNextStart) { + it('should have data routes in routes-manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/routes-manifest.json') + ) + + for (const route of manifest.dataRoutes) { + route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex) + } + + expect(manifest.dataRoutes).toEqual([ + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/index.json$` + ), + page: '/', + }, + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/([^/]+?)\\.json$` + ), + namedDataRouteRegex: `^/_next/data/${escapeStringRegexp( + next.buildId + )}/(?[^/]+?)\\.json$`, + page: '/[id]', + routeKeys: { + id: 'id', + }, + }, + ]) + }) + } +}) diff --git a/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts b/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts new file mode 100644 index 0000000000..3fdef2e151 --- /dev/null +++ b/test/e2e/handle-non-hoisted-swc-helpers/index.test.ts @@ -0,0 +1,38 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('handle-non-hoisted-swc-helpers', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + + export function getServerSideProps() { + const helper = require('@swc/helpers/lib/_object_spread.js') + console.log(helper) + return { + props: { + now: Date.now() + } + } + } + `, + }, + installCommand: + 'npm install; mkdir -p node_modules/next/node_modules/@swc; mv node_modules/@swc/helpers node_modules/next/node_modules/@swc/', + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('hello world') + }) +}) diff --git a/test/e2e/i18n-api-support/index.test.ts b/test/e2e/i18n-api-support/index.test.ts new file mode 100644 index 0000000000..25ec819096 --- /dev/null +++ b/test/e2e/i18n-api-support/index.test.ts @@ -0,0 +1,74 @@ +import { createNext } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('i18n API support', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.end('hello world') + } + `, + 'pages/api/blog/[slug].js': ` + export default function handler(req, res) { + res.end('blog/[slug]') + } + `, + }, + nextConfig: { + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [], + afterFiles: [], + fallback: [ + { + source: '/api/:path*', + destination: 'https://example.vercel.sh/', + }, + ], + } + }, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should respond to normal API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/hello') + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello world') + }) + + it('should respond to normal dynamic API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/blog/first') + expect(res.status).toBe(200) + expect(await res.text()).toBe('blog/[slug]') + }) + + // TODO: re-enable after this is fixed to match on Vercel + if (!(global as any).isNextDeploy) { + it('should fallback rewrite non-matching API request', async () => { + const paths = [ + '/fr/api/hello', + '/en/api/blog/first', + '/en/api/non-existent', + '/api/non-existent', + ] + + for (const path of paths) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + } + }) + } +}) diff --git a/test/e2e/i18n-data-fetching-redirect/app/next.config.js b/test/e2e/i18n-data-fetching-redirect/app/next.config.js new file mode 100644 index 0000000000..1478f3c05a --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'sv'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-blocking-redirect/[locale].js b/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-blocking-redirect/[locale].js new file mode 100644 index 0000000000..b8db3815ed --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-blocking-redirect/[locale].js @@ -0,0 +1,21 @@ +export async function getStaticProps(ctx) { + let toLocale = ctx.params.locale + if (toLocale === 'from-ctx') { + toLocale = ctx.locale + } + + return { + redirect: { + destination: `/${toLocale}/home`, + permanent: false, + }, + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} + +export default function Component() { + return 'gsp-blocking-redirect' +} diff --git a/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-fallback-redirect/[locale].js b/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-fallback-redirect/[locale].js new file mode 100644 index 0000000000..af0c391586 --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/pages/gsp-fallback-redirect/[locale].js @@ -0,0 +1,21 @@ +export async function getStaticProps(ctx) { + let toLocale = ctx.params.locale + if (toLocale === 'from-ctx') { + toLocale = ctx.locale + } + + return { + redirect: { + destination: `/${toLocale}/home`, + permanent: false, + }, + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: true } +} + +export default function Component() { + return 'gsp-fallback-redirect' +} diff --git a/test/e2e/i18n-data-fetching-redirect/app/pages/gssp-redirect/[locale].js b/test/e2e/i18n-data-fetching-redirect/app/pages/gssp-redirect/[locale].js new file mode 100644 index 0000000000..c6bad515ad --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/pages/gssp-redirect/[locale].js @@ -0,0 +1,17 @@ +export async function getServerSideProps(ctx) { + let toLocale = ctx.params.locale + if (toLocale === 'from-ctx') { + toLocale = ctx.locale + } + + return { + redirect: { + destination: `/${toLocale}/home`, + permanent: false, + }, + } +} + +export default function Component() { + return 'gssp-redirect' +} diff --git a/test/e2e/i18n-data-fetching-redirect/app/pages/home.js b/test/e2e/i18n-data-fetching-redirect/app/pages/home.js new file mode 100644 index 0000000000..a9b09b989e --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/pages/home.js @@ -0,0 +1,12 @@ +import { useRouter } from 'next/router' + +export default function Component() { + const router = useRouter() + return ( + <> +

{router.locale}

+

{router.pathname}

+

{router.asPath}

+ + ) +} diff --git a/test/e2e/i18n-data-fetching-redirect/app/pages/index.js b/test/e2e/i18n-data-fetching-redirect/app/pages/index.js new file mode 100644 index 0000000000..776c448bea --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/app/pages/index.js @@ -0,0 +1,43 @@ +import Link from 'next/link' + +export default function Component() { + return ( + <> + + to /gssp-redirect/en" + + + to /gssp-redirect/sv" + + + to /gssp-redirect/from-ctx + + + + to /gsp-blocking-redirect/en" + + + to /gsp-blocking-redirect/sv" + + + to /gsp-blocking-redirect/from-ctx + + + + to /gsp-fallback-redirect/en" + + + to /gsp-fallback-redirect/sv" + + + to /gsp-fallback-redirect/from-ctx + + + ) +} diff --git a/test/e2e/i18n-data-fetching-redirect/index.test.ts b/test/e2e/i18n-data-fetching-redirect/index.test.ts new file mode 100644 index 0000000000..bcd1cb40fc --- /dev/null +++ b/test/e2e/i18n-data-fetching-redirect/index.test.ts @@ -0,0 +1,145 @@ +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import webdriver from 'next-webdriver' + +describe('i18n-data-fetching-redirect', () => { + let next: NextInstance + + // TODO: investigate tests failures on deploy + if ((global as any).isNextDeploy) { + it('should skip temporarily', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + describe('Redirect to another locale', () => { + test.each` + path | fromLocale | toLocale + ${'gssp-redirect'} | ${'en'} | ${'sv'} + ${'gssp-redirect'} | ${'sv'} | ${'en'} + ${'gsp-blocking-redirect'} | ${'en'} | ${'sv'} + ${'gsp-blocking-redirect'} | ${'sv'} | ${'en'} + ${'gsp-fallback-redirect'} | ${'en'} | ${'sv'} + ${'gsp-fallback-redirect'} | ${'sv'} | ${'en'} + `( + '$path $fromLocale -> $toLocale', + async ({ path, fromLocale, toLocale }) => { + const browser = await webdriver( + next.url, + `/${fromLocale}/${path}/${toLocale}` + ) + + await check( + () => browser.eval('window.location.pathname'), + `/${toLocale}/home` + ) + expect(await browser.elementByCss('#router-locale').text()).toBe( + toLocale + ) + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/home' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/home' + ) + } + ) + + test.each` + path | fromLocale | toLocale + ${'gssp-redirect'} | ${'en'} | ${'sv'} + ${'gssp-redirect'} | ${'sv'} | ${'en'} + ${'gsp-blocking-redirect'} | ${'en'} | ${'sv'} + ${'gsp-blocking-redirect'} | ${'sv'} | ${'en'} + ${'gsp-fallback-redirect'} | ${'en'} | ${'sv'} + ${'gsp-fallback-redirect'} | ${'sv'} | ${'en'} + `( + 'next/link $path $fromLocale -> $toLocale', + async ({ path, fromLocale, toLocale }) => { + const browser = await webdriver(next.url, `/${fromLocale}`) + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss(`#to-${path}-${toLocale}`).click() + + await check( + () => browser.eval('window.location.pathname'), + `/${toLocale}/home` + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.elementByCss('#router-locale').text()).toBe( + toLocale + ) + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/home' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/home' + ) + } + ) + }) + + describe('Redirect to locale from context', () => { + test.each` + path | locale + ${'gssp-redirect'} | ${'en'} + ${'gssp-redirect'} | ${'sv'} + ${'gsp-blocking-redirect'} | ${'en'} + ${'gsp-blocking-redirect'} | ${'sv'} + ${'gsp-fallback-redirect'} | ${'en'} + ${'gsp-fallback-redirect'} | ${'sv'} + `('$path $locale', async ({ path, locale }) => { + const browser = await webdriver(next.url, `/${locale}/${path}/from-ctx`) + + await check( + () => browser.eval('window.location.pathname'), + `/${locale}/home` + ) + expect(await browser.elementByCss('#router-locale').text()).toBe(locale) + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/home' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe('/home') + }) + + test.each` + path | locale + ${'gssp-redirect'} | ${'en'} + ${'gssp-redirect'} | ${'sv'} + ${'gsp-blocking-redirect'} | ${'en'} + ${'gsp-blocking-redirect'} | ${'sv'} + ${'gsp-fallback-redirect'} | ${'en'} + ${'gsp-fallback-redirect'} | ${'sv'} + `('next/link $path $locale', async ({ path, locale }) => { + const browser = await webdriver(next.url, `/${locale}`) + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss(`#to-${path}-from-ctx`).click() + + await check( + () => browser.eval('window.location.pathname'), + `/${locale}/home` + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.elementByCss('#router-locale').text()).toBe(locale) + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/home' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe('/home') + }) + }) +}) diff --git a/test/e2e/i18n-ignore-redirect-source-locale/app/pages/newpage.js b/test/e2e/i18n-ignore-redirect-source-locale/app/pages/newpage.js new file mode 100644 index 0000000000..4a19d965bb --- /dev/null +++ b/test/e2e/i18n-ignore-redirect-source-locale/app/pages/newpage.js @@ -0,0 +1,6 @@ +import { useRouter } from 'next/router' + +export default function Page() { + const router = useRouter() + return

{router.locale}

+} diff --git a/test/e2e/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts b/test/e2e/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts new file mode 100644 index 0000000000..1d6598223f --- /dev/null +++ b/test/e2e/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts @@ -0,0 +1,91 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-redirect-source-locale with basepath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + dependencies: {}, + nextConfig: { + basePath: '/basepath', + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async redirects() { + return [ + { + source: '/:locale/to-sv', + destination: '/sv/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-en', + destination: '/en/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-slash', + destination: '/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-same', + destination: '/:locale/newpage', + permanent: false, + locale: false, + }, + ] + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get redirected to the new page, from: %s to: sv', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-sv`) + await check(() => browser.elementById('current-locale').text(), 'sv') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: en', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-en`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: /', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-slash`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from and to: %s', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-same`) + await check( + () => browser.elementById('current-locale').text(), + locale === '' ? 'en' : locale.slice(1) + ) + } + ) +}) diff --git a/test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts b/test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts new file mode 100644 index 0000000000..25e328d40f --- /dev/null +++ b/test/e2e/i18n-ignore-redirect-source-locale/redirects.test.ts @@ -0,0 +1,90 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-redirect-source-locale', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + dependencies: {}, + nextConfig: { + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async redirects() { + return [ + { + source: '/:locale/to-sv', + destination: '/sv/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-en', + destination: '/en/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-slash', + destination: '/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-same', + destination: '/:locale/newpage', + permanent: false, + locale: false, + }, + ] + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get redirected to the new page, from: %s to: sv', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-sv`) + await check(() => browser.elementById('current-locale').text(), 'sv') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: en', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-en`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: /', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-slash`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from and to: %s', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-same`) + await check( + () => browser.elementById('current-locale').text(), + locale === '' ? 'en' : locale.slice(1) + ) + } + ) +}) diff --git a/test/e2e/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts b/test/e2e/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts new file mode 100644 index 0000000000..f5dccada46 --- /dev/null +++ b/test/e2e/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts @@ -0,0 +1,94 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-rewrite-source-locale with basepath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.send('hello from api') + }`, + 'public/file.txt': 'hello from file.txt', + }, + dependencies: {}, + nextConfig: { + basePath: '/basepath', + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:locale/rewrite-files/:path*', + destination: '/:path*', + locale: false, + }, + { + source: '/:locale/rewrite-api/:path*', + destination: '/api/:path*', + locale: false, + }, + ], + afterFiles: [], + fallback: [], + } + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get public file by skipping locale in rewrite, locale: %s', + async (locale) => { + const res = await renderViaHTTP( + next.url, + `/basepath${locale}/rewrite-files/file.txt` + ) + expect(res).toContain('hello from file.txt') + } + ) + + test.each(locales)( + 'call api by skipping locale in rewrite, locale: %s', + async (locale) => { + const res = await renderViaHTTP( + next.url, + `/basepath${locale}/rewrite-api/hello` + ) + expect(res).toContain('hello from api') + } + ) + + // build artifacts aren't available on deploy + if (!(global as any).isNextDeploy) { + test.each(locales)( + 'get _next/static/ files by skipping locale in rewrite, locale: %s', + async (locale) => { + const chunks = ( + await fs.readdir(path.join(next.testDir, '.next', 'static', 'chunks')) + ).filter((f) => f.endsWith('.js')) + + await Promise.all( + chunks.map(async (file) => { + const res = await fetchViaHTTP( + next.url, + `/basepath${locale}/rewrite-files/_next/static/chunks/${file}` + ) + expect(res.status).toBe(200) + }) + ) + } + ) + } +}) diff --git a/test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts b/test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts new file mode 100644 index 0000000000..2f34b82174 --- /dev/null +++ b/test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts @@ -0,0 +1,90 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-rewrite-source-locale', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.send('hello from api') + }`, + 'public/file.txt': 'hello from file.txt', + }, + dependencies: {}, + nextConfig: { + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:locale/rewrite-files/:path*', + destination: '/:path*', + locale: false, + }, + { + source: '/:locale/rewrite-api/:path*', + destination: '/api/:path*', + locale: false, + }, + ], + afterFiles: [], + fallback: [], + } + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get public file by skipping locale in rewrite, locale: %s', + async (locale) => { + const res = await renderViaHTTP( + next.url, + `${locale}/rewrite-files/file.txt` + ) + expect(res).toContain('hello from file.txt') + } + ) + + test.each(locales)( + 'call api by skipping locale in rewrite, locale: %s', + async (locale) => { + const res = await renderViaHTTP(next.url, `${locale}/rewrite-api/hello`) + expect(res).toContain('hello from api') + } + ) + + // build artifacts aren't available on deploy + if (!(global as any).isNextDeploy) { + test.each(locales)( + 'get _next/static/ files by skipping locale in rewrite, locale: %s', + async (locale) => { + const chunks = ( + await fs.readdir(path.join(next.testDir, '.next', 'static', 'chunks')) + ).filter((f) => f.endsWith('.js')) + + await Promise.all( + chunks.map(async (file) => { + const res = await fetchViaHTTP( + next.url, + `${locale}/rewrite-files/_next/static/chunks/${file}` + ) + expect(res.status).toBe(200) + }) + ) + } + ) + } +}) diff --git a/test/e2e/ignore-invalid-popstateevent/app/next.config.js b/test/e2e/ignore-invalid-popstateevent/app/next.config.js new file mode 100644 index 0000000000..1478f3c05a --- /dev/null +++ b/test/e2e/ignore-invalid-popstateevent/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'sv'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/ignore-invalid-popstateevent/app/pages/[dynamic].js b/test/e2e/ignore-invalid-popstateevent/app/pages/[dynamic].js new file mode 100644 index 0000000000..b3ecc6dcc3 --- /dev/null +++ b/test/e2e/ignore-invalid-popstateevent/app/pages/[dynamic].js @@ -0,0 +1,3 @@ +export default function DynamicPage() { + return

dynamic

+} diff --git a/test/e2e/ignore-invalid-popstateevent/app/pages/static.js b/test/e2e/ignore-invalid-popstateevent/app/pages/static.js new file mode 100644 index 0000000000..c5a85c3c1c --- /dev/null +++ b/test/e2e/ignore-invalid-popstateevent/app/pages/static.js @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

static

+} diff --git a/test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts b/test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts new file mode 100644 index 0000000000..370804df93 --- /dev/null +++ b/test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts @@ -0,0 +1,94 @@ +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, waitFor } from 'next-test-utils' +import webdriver from 'next-webdriver' + +import type { HistoryState } from '../../../packages/next/shared/lib/router/router' +import { BrowserInterface } from 'test/lib/browsers/base' + +const emitPopsStateEvent = (browser: BrowserInterface, state: HistoryState) => + browser.eval( + `window.dispatchEvent(new PopStateEvent("popstate", { state: ${JSON.stringify( + state + )} }))` + ) + +describe('i18n: Event with stale state - static route previously was dynamic', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + test('Ignore event without query param', async () => { + const browser = await webdriver(next.url, '/sv/static') + browser.close() + + const state: HistoryState = { + url: '/[dynamic]?', + as: '/static', + options: { locale: 'sv' }, + __N: true, + key: '', + } + + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 1st event is ignored + await emitPopsStateEvent(browser, state) + await waitFor(1000) + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 2nd event isn't ignored + await emitPopsStateEvent(browser, state) + await check(() => browser.elementByCss('#page-type').text(), 'dynamic') + }) + + test('Ignore event with query param', async () => { + const browser = await webdriver(next.url, '/sv/static?param=1') + + const state: HistoryState = { + url: '/[dynamic]?param=1', + as: '/static?param=1', + options: { locale: 'sv' }, + __N: true, + key: '', + } + + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 1st event is ignored + await emitPopsStateEvent(browser, state) + await waitFor(1000) + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 2nd event isn't ignored + await emitPopsStateEvent(browser, state) + await check(() => browser.elementByCss('#page-type').text(), 'dynamic') + }) + + test("Don't ignore event with different locale", async () => { + const browser = await webdriver(next.url, '/sv/static?param=1') + + const state: HistoryState = { + url: '/[dynamic]?param=1', + as: '/static?param=1', + options: { locale: 'en' }, + __N: true, + key: '', + } + + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + await emitPopsStateEvent(browser, state) + await check(() => browser.elementByCss('#page-type').text(), 'dynamic') + }) +}) diff --git a/test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts b/test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts new file mode 100644 index 0000000000..b3c3a0393e --- /dev/null +++ b/test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts @@ -0,0 +1,77 @@ +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, waitFor } from 'next-test-utils' +import webdriver from 'next-webdriver' + +import type { HistoryState } from '../../../packages/next/shared/lib/router/router' +import { BrowserInterface } from 'test/lib/browsers/base' + +const emitPopsStateEvent = (browser: BrowserInterface, state: HistoryState) => + browser.eval( + `window.dispatchEvent(new PopStateEvent("popstate", { state: ${JSON.stringify( + state + )} }))` + ) + +describe('Event with stale state - static route previously was dynamic', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + // Don't use next.config.js to avoid getting i18n + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + test('Ignore event without query param', async () => { + const browser = await webdriver(next.url, '/static') + browser.close() + + const state: HistoryState = { + url: '/[dynamic]?', + as: '/static', + options: {}, + __N: true, + key: '', + } + + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 1st event is ignored + await emitPopsStateEvent(browser, state) + await waitFor(1000) + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 2nd event isn't ignored + await emitPopsStateEvent(browser, state) + await check(() => browser.elementByCss('#page-type').text(), 'dynamic') + }) + + test('Ignore event with query param', async () => { + const browser = await webdriver(next.url, '/static?param=1') + + const state: HistoryState = { + url: '/[dynamic]?param=1', + as: '/static?param=1', + options: {}, + __N: true, + key: '', + } + + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 1st event is ignored + await emitPopsStateEvent(browser, state) + await waitFor(1000) + expect(await browser.elementByCss('#page-type').text()).toBe('static') + + // 2nd event isn't ignored + await emitPopsStateEvent(browser, state) + await check(() => browser.elementByCss('#page-type').text(), 'dynamic') + }) +}) diff --git a/test/e2e/link-with-api-rewrite/app/next.config.js b/test/e2e/link-with-api-rewrite/app/next.config.js new file mode 100644 index 0000000000..451e3d8fbd --- /dev/null +++ b/test/e2e/link-with-api-rewrite/app/next.config.js @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:path(.*)', + has: [{ type: 'query', key: 'json', value: 'true' }], + destination: '/api/json?from=/:path', + }, + ], + } + }, +} + +module.exports = nextConfig diff --git a/test/e2e/link-with-api-rewrite/app/pages/api/json.js b/test/e2e/link-with-api-rewrite/app/pages/api/json.js new file mode 100644 index 0000000000..ea288af12d --- /dev/null +++ b/test/e2e/link-with-api-rewrite/app/pages/api/json.js @@ -0,0 +1,5 @@ +export default async function handler(req, res) { + const from = req.query.from || '' + + return res.json({ from }) +} diff --git a/test/e2e/link-with-api-rewrite/app/pages/index.js b/test/e2e/link-with-api-rewrite/app/pages/index.js new file mode 100644 index 0000000000..a5324fc832 --- /dev/null +++ b/test/e2e/link-with-api-rewrite/app/pages/index.js @@ -0,0 +1,14 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to /some/route/for?json=true + + + to /api/json + +
+ ) +} diff --git a/test/e2e/link-with-api-rewrite/index.test.ts b/test/e2e/link-with-api-rewrite/index.test.ts new file mode 100644 index 0000000000..31fccb1a42 --- /dev/null +++ b/test/e2e/link-with-api-rewrite/index.test.ts @@ -0,0 +1,74 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +describe('link-with-api-rewrite', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should perform hard navigation for rewritten urls', async () => { + const browser = await webdriver(next.url, '/') + + try { + // Click the link on the page, we expect that there will be a hard + // navigation later (we do this be checking that the window global is + // unset). + await browser.eval('window.beforeNav = "hi"') + await browser.elementById('rewrite').click() + await check(() => browser.eval('window.beforeNav'), { + test: (content) => content !== 'hi', + }) + + // Check to see that we were in fact navigated to the correct page. + const pathname = await browser.eval('window.location.pathname') + expect(pathname).toBe('/some/route/for') + + // Check to see that the resulting data is coming from the right endpoint. + const text = await browser.eval( + 'window.document.documentElement.innerText' + ) + expect(text).toBe('{"from":"/some/route/for"}') + } finally { + await browser.close() + } + }) + + it('should perform hard navigation for direct urls', async () => { + const browser = await webdriver(next.url, '/') + + try { + // Click the link on the page, we expect that there will be a hard + // navigation later (we do this be checking that the window global is + // unset). + await browser.eval('window.beforeNav = "hi"') + await browser.elementById('direct').click() + await check(() => browser.eval('window.beforeNav'), { + test: (content) => content !== 'hi', + }) + + // Check to see that we were in fact navigated to the correct page. + const pathname = await browser.eval('window.location.pathname') + expect(pathname).toBe('/api/json') + + // Check to see that the resulting data is coming from the right endpoint. + const text = await browser.eval( + 'window.document.documentElement.innerText' + ) + expect(text).toBe('{"from":""}') + } finally { + await browser.close() + } + }) +}) diff --git a/test/e2e/manual-client-base-path/app/next.config.js b/test/e2e/manual-client-base-path/app/next.config.js new file mode 100644 index 0000000000..a67cbf980f --- /dev/null +++ b/test/e2e/manual-client-base-path/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + basePath: '/docs-proxy', + experimental: { + manualClientBasePath: true, + }, +} diff --git a/test/e2e/manual-client-base-path/app/pages/another.js b/test/e2e/manual-client-base-path/app/pages/another.js new file mode 100644 index 0000000000..15bd6c9c6e --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/another.js @@ -0,0 +1,50 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

another page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /index + +
+ + + to /dynamic/first + +
+ + ) +} + +export function getServerSideProps() { + return { + props: { + hello: 'world', + now: Date.now(), + }, + } +} diff --git a/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js new file mode 100644 index 0000000000..fedfd393ac --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/dynamic/[slug].js @@ -0,0 +1,58 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

dynamic page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /index + +
+ + + to /dynamic/second + +
+ + ) +} + +export function getStaticPaths() { + return { + paths: ['/dynamic/first'], + fallback: true, + } +} + +export function getStaticProps({ params }) { + return { + props: { + params, + hello: 'world', + now: Date.now(), + }, + } +} diff --git a/test/e2e/manual-client-base-path/app/pages/index.js b/test/e2e/manual-client-base-path/app/pages/index.js new file mode 100644 index 0000000000..3690d1d40f --- /dev/null +++ b/test/e2e/manual-client-base-path/app/pages/index.js @@ -0,0 +1,41 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + <> +

index page

+

{JSON.stringify(props)}

+

+ {JSON.stringify( + mounted + ? { + basePath: router.basePath, + pathname: router.pathname, + asPath: router.asPath, + query: router.query, + } + : {} + )} +

+ + + to /another + +
+ + + to /dynamic/first + +
+ + ) +} diff --git a/test/e2e/manual-client-base-path/index.test.ts b/test/e2e/manual-client-base-path/index.test.ts new file mode 100644 index 0000000000..a113f2df8d --- /dev/null +++ b/test/e2e/manual-client-base-path/index.test.ts @@ -0,0 +1,203 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import httpProxy from 'http-proxy' +import { join } from 'path' +import http from 'http' +import webdriver from 'next-webdriver' +import assert from 'assert' +import { check, renderViaHTTP, waitFor } from 'next-test-utils' + +describe('manual-client-base-path', () => { + if ((global as any).isNextDeploy) { + it('should skip deploy', () => {}) + return + } + + let next: NextInstance + let server: http.Server + let appPort: string + const basePath = '/docs-proxy' + const responses = new Set() + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + const getProxyTarget = (req) => { + const destination = new URL(next.url) + const reqUrl = new URL(req.url, 'http://localhost') + // force IPv4 for testing in node 17+ as the default + // switched to favor IPv6 over IPv4 + destination.hostname = '127.0.0.1' + + if (req.url.startsWith(basePath)) { + destination.pathname = reqUrl.pathname || '/' + } else { + destination.pathname = `${basePath}${ + reqUrl.pathname === '/' ? '' : reqUrl.pathname + }` + } + reqUrl.searchParams.forEach((value, key) => { + destination.searchParams.set(key, value) + }) + + console.log('proxying', req.url, 'to:', destination.toString()) + return destination + } + + server = http + .createServer((req, res) => { + responses.add(res) + res.on('close', () => responses.delete(res)) + + const destination = getProxyTarget(req) + const proxy = httpProxy.createProxy({ + changeOrigin: true, + ignorePath: true, + xfwd: true, + proxyTimeout: 30_000, + target: destination.toString(), + }) + + proxy.on('error', (err) => console.error(err)) + proxy.web(req, res) + }) + .listen(0) + + server.on('upgrade', (req, socket, head) => { + responses.add(socket) + socket.on('close', () => responses.delete(socket)) + + const destination = getProxyTarget(req) + const proxy = httpProxy.createProxy({ + changeOrigin: true, + ignorePath: true, + xfwd: true, + proxyTimeout: 30_000, + target: destination.toString(), + }) + + proxy.on('error', (err) => console.error(err)) + proxy.ws(req, socket, head) + }) + + // @ts-ignore type is incorrect + appPort = server.address().port + }) + afterAll(async () => { + await next.destroy() + try { + server.close() + responses.forEach((res: any) => res.end?.() || res.close?.()) + } catch (err) { + console.error(err) + } + }) + + it('should not warn for flag in output', async () => { + await renderViaHTTP(next.url, '/') + expect(next.cliOutput).not.toContain('exist in this version of Next.js') + }) + + for (const [asPath, pathname, query] of [ + ['/'], + ['/another'], + ['/dynamic/first', '/dynamic/[slug]', { slug: 'first' }], + ['/dynamic/second', '/dynamic/[slug]', { slug: 'second' }], + ]) { + // eslint-disable-next-line + it(`should not update with basePath on mount ${asPath}`, async () => { + const fullAsPath = (asPath as string) + '?update=1' + const browser = await webdriver(appPort, fullAsPath) + await browser.eval('window.beforeNav = 1') + + expect(await browser.eval('window.location.pathname')).toBe(asPath) + expect(await browser.eval('window.location.search')).toBe('?update=1') + + await check(async () => { + assert.deepEqual( + JSON.parse(await browser.elementByCss('#router').text()), + { + asPath: fullAsPath, + pathname: pathname || asPath, + query: { + update: '1', + ...((query as any) || {}), + }, + basePath, + } + ) + return 'success' + }, 'success') + + await waitFor(5 * 1000) + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + } + + it('should navigate correctly from index', async () => { + const browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-another').click() + await check(() => browser.elementByCss('#page').text(), 'another page') + expect(await browser.eval('window.location.pathname')).toBe('/another') + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.forward() + await check(() => browser.elementByCss('#page').text(), 'another page') + expect(await browser.eval('window.location.pathname')).toBe('/another') + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.elementByCss('#to-dynamic').click() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + await browser.back() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.forward() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should navigate correctly from another', async () => { + const browser = await webdriver(appPort, '/another') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-index').click() + await check(() => browser.elementByCss('#page').text(), 'index page') + expect(await browser.eval('window.location.pathname')).toBe('/') + + await browser.elementByCss('#to-dynamic').click() + await check(() => browser.elementByCss('#page').text(), 'dynamic page') + expect(await browser.eval('window.location.pathname')).toBe( + '/dynamic/first' + ) + + await browser.elementByCss('#to-dynamic').click() + await check( + () => browser.eval('window.location.pathname'), + '/dynamic/second' + ) + + expect(await browser.eval('window.beforeNav')).toBe(1) + }) +}) diff --git a/test/e2e/middleware-base-path/app/middleware.js b/test/e2e/middleware-base-path/app/middleware.js new file mode 100644 index 0000000000..2e1bcab993 --- /dev/null +++ b/test/e2e/middleware-base-path/app/middleware.js @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + if ( + request.method === 'HEAD' && + url.basePath === '/root' && + url.pathname === '/redirect-me-to-about' + ) { + url.pathname = '/about' + return NextResponse.redirect(url) + } + + if (url.pathname === '/redirect-with-basepath' && !url.basePath) { + url.basePath = '/root' + return NextResponse.redirect(url) + } + + if (url.pathname === '/redirect-with-basepath') { + url.pathname = '/about' + return NextResponse.rewrite(url) + } +} diff --git a/test/e2e/middleware-base-path/app/next.config.js b/test/e2e/middleware-base-path/app/next.config.js new file mode 100644 index 0000000000..959fceae74 --- /dev/null +++ b/test/e2e/middleware-base-path/app/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + basePath: '/root', +} diff --git a/test/e2e/middleware-base-path/app/pages/about.js b/test/e2e/middleware-base-path/app/pages/about.js new file mode 100644 index 0000000000..9924acf037 --- /dev/null +++ b/test/e2e/middleware-base-path/app/pages/about.js @@ -0,0 +1,7 @@ +export default function About() { + return ( +
+

About Page

+
+ ) +} diff --git a/test/e2e/middleware-base-path/app/pages/dynamic-routes/[routeName].js b/test/e2e/middleware-base-path/app/pages/dynamic-routes/[routeName].js new file mode 100644 index 0000000000..1fe4784b68 --- /dev/null +++ b/test/e2e/middleware-base-path/app/pages/dynamic-routes/[routeName].js @@ -0,0 +1,11 @@ +import { useRouter } from 'next/router' + +export default function DynamicRoutes() { + const { query } = useRouter() + + return ( +
+

{query.routeName}

+
+ ) +} diff --git a/test/e2e/middleware-base-path/app/pages/index.js b/test/e2e/middleware-base-path/app/pages/index.js new file mode 100644 index 0000000000..a65b40f43f --- /dev/null +++ b/test/e2e/middleware-base-path/app/pages/index.js @@ -0,0 +1,41 @@ +import Link from 'next/link' + +export default function Main({ message }) { + return ( +
+

Hello {message}

+
    +
  • + Stream a response +
  • +
  • + + Rewrite me to about + +
  • +
  • + Rewrite me to Vercel +
  • +
  • + redirect me to about +
  • +
  • + + Hello World + +
  • +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || 'World' }, +}) diff --git a/test/e2e/middleware-base-path/test/index.test.ts b/test/e2e/middleware-base-path/test/index.test.ts new file mode 100644 index 0000000000..dffe7a1e79 --- /dev/null +++ b/test/e2e/middleware-base-path/test/index.test.ts @@ -0,0 +1,48 @@ +/* eslint-env jest */ +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('Middleware base tests', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + afterAll(() => next.destroy()) + + it('should execute from absolute paths', async () => { + const browser = await webdriver(next.url, '/redirect-with-basepath') + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + '/root/redirect-with-basepath' + ) + } finally { + await browser.close() + } + + const res = await fetchViaHTTP(next.url, '/root/redirect-with-basepath') + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) + it('router.query must exist when Link clicked page routing', async () => { + const browser = await webdriver(next.url, '/root') + try { + await browser.elementById('go-to-hello-world-anchor').click() + const routeName = await browser.elementById('route-name').text() + expect(routeName).toMatch('hello-world') + } finally { + await browser.close() + } + }) +}) diff --git a/test/e2e/middleware-custom-matchers-basepath/app/middleware.js b/test/e2e/middleware-custom-matchers-basepath/app/middleware.js new file mode 100644 index 0000000000..0760a6e4ae --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/middleware.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const nextUrl = request.nextUrl.clone() + nextUrl.pathname = '/' + const res = NextResponse.rewrite(nextUrl) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { + source: '/hello', + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/next.config.js b/test/e2e/middleware-custom-matchers-basepath/app/next.config.js new file mode 100644 index 0000000000..ee95502b60 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + basePath: '/docs', +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js b/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js new file mode 100644 index 0000000000..d6e9dda8ea --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/app/pages/routes.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default (props) => ( +
    +
  • + + /hello + +
  • +
+) diff --git a/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts b/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts new file mode 100644 index 0000000000..ff367b9787 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-basepath/test/index.test.ts @@ -0,0 +1,56 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers basePath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + // FIXME + // See https://linear.app/vercel/issue/EC-170/middleware-rewrite-of-nextjs-with-basepath-does-not-work-on-vercel + itif(!isModeDeploy)('should match', async () => { + for (const path of [ + '/docs/hello', + `/docs/_next/data/${next.buildId}/hello.json`, + ]) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + } + }) + + it.each(['/hello', '/invalid/docs/hello'])( + 'should not match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(404) + } + ) + + // FIXME: + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy)('should match has query on client routing', async () => { + const browser = await webdriver(next.url, '/docs/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('hello').click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + }) +}) diff --git a/test/e2e/middleware-custom-matchers-i18n/app/middleware.js b/test/e2e/middleware-custom-matchers-i18n/app/middleware.js new file mode 100644 index 0000000000..bdd67e48df --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/middleware.js @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const nextUrl = request.nextUrl.clone() + nextUrl.pathname = '/' + const res = NextResponse.rewrite(nextUrl) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { + source: '/hello', + }, + { + source: '/nl-NL/about', + locale: false, + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/next.config.js b/test/e2e/middleware-custom-matchers-i18n/app/next.config.js new file mode 100644 index 0000000000..97c6addb6f --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'nl-NL'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js b/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js new file mode 100644 index 0000000000..de7469fb8b --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/app/pages/routes.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export default (props) => ( +
    +
  • + + /hello + +
  • +
  • + + /en/hello + +
  • +
  • + + /nl-NL/hello + +
  • +
  • + + /nl-NL/about + +
  • +
+) diff --git a/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts b/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts new file mode 100644 index 0000000000..274e81b7e1 --- /dev/null +++ b/test/e2e/middleware-custom-matchers-i18n/test/index.test.ts @@ -0,0 +1,55 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers i18n', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + it.each(['/hello', '/en/hello', '/nl-NL/hello', '/nl-NL/about'])( + 'should match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + } + ) + + it.each(['/invalid/hello', '/hello/invalid', '/about', '/en/about'])( + 'should not match', + async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(404) + } + ) + + // FIXME: + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy).each(['hello', 'en_hello', 'nl-NL_hello', 'nl-NL_about'])( + 'should match has query on client routing', + async (id) => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById(id).click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) +}) diff --git a/test/e2e/middleware-custom-matchers/app/middleware.js b/test/e2e/middleware-custom-matchers/app/middleware.js new file mode 100644 index 0000000000..99ec191d35 --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/middleware.js @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const res = NextResponse.rewrite(new URL('/', request.url)) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { source: '/source-match' }, + { + source: '/has-match-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + }, + { + source: '/has-match-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + }, + { + source: '/has-match-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: '(?true)', + }, + ], + }, + { + source: '/has-match-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + }, + { + source: '/has-match-5', + has: [ + { + type: 'header', + key: 'hasParam', + value: 'with-params', + }, + ], + }, + { + source: '/missing-match-1', + missing: [ + { + type: 'header', + key: 'hello', + value: '(.*)', + }, + ], + }, + { + source: '/missing-match-2', + missing: [ + { + type: 'query', + key: 'test', + value: 'value', + }, + ], + }, + ], +} diff --git a/test/e2e/middleware-custom-matchers/app/pages/index.js b/test/e2e/middleware-custom-matchers/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/middleware-custom-matchers/app/pages/routes.js b/test/e2e/middleware-custom-matchers/app/pages/routes.js new file mode 100644 index 0000000000..9cf902de88 --- /dev/null +++ b/test/e2e/middleware-custom-matchers/app/pages/routes.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +export default (props) => ( +
    +
  • + + has-match-2 + +
  • +
  • + + has-match-3 + +
  • +
+) diff --git a/test/e2e/middleware-custom-matchers/test/index.test.ts b/test/e2e/middleware-custom-matchers/test/index.test.ts new file mode 100644 index 0000000000..57bc8b2d62 --- /dev/null +++ b/test/e2e/middleware-custom-matchers/test/index.test.ts @@ -0,0 +1,167 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + const runTests = () => { + it('should match missing header correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-1') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-1', undefined, { + headers: { + hello: 'world', + }, + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + + it('should match missing query correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-2') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-2', { + test: 'value', + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + + it('should match source path', async () => { + const res = await fetchViaHTTP(next.url, '/source-match') + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + }) + + it('should match has header', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-1') + expect(res2.status).toBe(404) + }) + + it('should match has query', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-2', { + 'my-query': 'hellooo', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-2') + expect(res2.status).toBe(404) + }) + + it('should match has cookie', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=false', + }, + }) + expect(res2.status).toBe(404) + }) + + // Cannot modify host when testing with real deployment + itif(!isModeDeploy)('should match has host', async () => { + const res1 = await fetchViaHTTP(next.url, '/has-match-4') + expect(res1.status).toBe(404) + + const res = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.com', + }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.org', + }, + }) + expect(res2.status).toBe(404) + }) + + it('should match has header value', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'with-params', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'without-params', + }, + }) + expect(res2.status).toBe(404) + }) + + // FIXME: Test fails on Vercel deployment for now. + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy)( + 'should match has query on client routing', + async () => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-2').click() + const fromMiddleware = await browser + .elementById('from-middleware') + .text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) + + itif(!isModeDeploy)( + 'should match has cookie on client routing', + async () => { + const browser = await webdriver(next.url, '/routes') + await browser.addCookie({ name: 'loggedIn', value: 'true' }) + await browser.refresh() + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-3').click() + const fromMiddleware = await browser + .elementById('from-middleware') + .text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + } + ) + } + runTests() +}) diff --git a/test/e2e/middleware-fetches-with-any-http-method/index.test.ts b/test/e2e/middleware-fetches-with-any-http-method/index.test.ts new file mode 100644 index 0000000000..fd8be9a47f --- /dev/null +++ b/test/e2e/middleware-fetches-with-any-http-method/index.test.ts @@ -0,0 +1,91 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Middleware fetches with any HTTP method', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/ping.js': ` + export default (req, res) => { + res.send(JSON.stringify({ + method: req.method, + headers: {...req.headers}, + })) + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + + const HTTP_ECHO_URL = 'https://http-echo-kou029w.vercel.app/'; + + export default async (req) => { + const kind = req.nextUrl.searchParams.get('kind') + const handler = handlers[kind] ?? handlers['normal-fetch']; + + const response = await handler({url: HTTP_ECHO_URL, method: req.method}); + const json = await response.text() + + const res = NextResponse.next(); + res.headers.set('x-resolved', json ?? '{}'); + return res + } + + const handlers = { + 'new-request': ({url, method}) => + fetch(new Request(url, { method, headers: { 'x-kind': 'new-request' } })), + + 'normal-fetch': ({url, method}) => + fetch(url, { method, headers: { 'x-kind': 'normal-fetch' } }) + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('passes the method on a direct fetch request', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/ping', + {}, + { method: 'POST' } + ) + const json = await response.json() + expect(json).toMatchObject({ + method: 'POST', + }) + + const headerJson = JSON.parse(response.headers.get('x-resolved')) + expect(headerJson).toMatchObject({ + method: 'POST', + headers: { + 'x-kind': 'normal-fetch', + }, + }) + }) + + it('passes the method when providing a Request object', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/ping', + { kind: 'new-request' }, + { method: 'POST' } + ) + const json = await response.json() + expect(json).toMatchObject({ + method: 'POST', + }) + + const headerJson = JSON.parse(response.headers.get('x-resolved')) + expect(headerJson).toMatchObject({ + method: 'POST', + headers: { + 'x-kind': 'new-request', + }, + }) + }) +}) diff --git a/test/e2e/middleware-fetches-with-body/index.test.ts b/test/e2e/middleware-fetches-with-body/index.test.ts new file mode 100644 index 0000000000..931b8ff67f --- /dev/null +++ b/test/e2e/middleware-fetches-with-body/index.test.ts @@ -0,0 +1,279 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Middleware fetches with body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/default.js': ` + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/size_limit_5kb.js': ` + export const config = { api: { bodyParser: { sizeLimit: '5kb' } } } + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/size_limit_5mb.js': ` + export const config = { api: { bodyParser: { sizeLimit: '5mb' } } } + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/body_parser_false.js': ` + export const config = { api: { bodyParser: false } } + + async function buffer(readable) { + const chunks = [] + for await (const chunk of readable) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks) + } + + export default async (req, res) => { + const buf = await buffer(req) + const rawBody = buf.toString('utf8'); + + res.json({ rawBody, body: req.body }) + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + + export default async (req) => NextResponse.next(); + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + describe('with default bodyParser sizeLimit (1mb)', () => { + it('should return 413 for body greater than 1mb', async () => { + const bodySize = 1024 * 1024 + 1 + const body = 'r'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 1mb limit') + } + }) + + it('should be able to send and return body size equal to 1mb', async () => { + const bodySize = 1024 * 1024 + const body = 'B1C2D3E4F5G6H7I8J9K0LaMbNcOdPeQf'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('B1C2D3E4F5G6H7I8J9K0LaMbNcOdPeQf').length).toBe( + bodySize / 32 + 1 + ) + }) + + it('should be able to send and return body greater than default highWaterMark (16KiB)', async () => { + const bodySize = 16 * 1024 + 1 + const body = + 'CD1E2F3G4H5I6J7K8L9M0NaObPcQdReS'.repeat(bodySize / 32) + 'C' + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('CD1E2F3G4H5I6J7K8L9M0NaObPcQdReS').length).toBe( + 512 + 1 + ) + }) + }) + + describe('with custom bodyParser sizeLimit (5kb)', () => { + it('should return 413 for body greater than 5kb', async () => { + const bodySize = 5 * 1024 + 1 + const body = 's'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5kb', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5kb limit') + } + }) + + it('should be able to send and return body size equal to 5kb', async () => { + const bodySize = 5120 + const body = 'DEF1G2H3I4J5K6L7M8N9O0PaQbRcSdTe'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5kb', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('DEF1G2H3I4J5K6L7M8N9O0PaQbRcSdTe').length).toBe( + bodySize / 32 + 1 + ) + }) + }) + + describe('with custom bodyParser sizeLimit (5mb)', () => { + it('should return 413 for body equal to 10mb', async () => { + const bodySize = 10 * 1024 * 1024 + const body = 't'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5mb limit') + } + }) + + it('should return 413 for body greater than 5mb', async () => { + const bodySize = 5 * 1024 * 1024 + 1 + const body = 'u'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + } + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5mb limit') + } + }) + + if (!(global as any).isNextDeploy) { + it('should be able to send and return body size equal to 5mb', async () => { + const bodySize = 5 * 1024 * 1024 + const body = 'FGHI1J2K3L4M5N6O7P8Q9R0SaTbUcVdW'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('FGHI1J2K3L4M5N6O7P8Q9R0SaTbUcVdW').length).toBe( + bodySize / 32 + 1 + ) + }) + } + }) + + describe('with bodyParser = false', () => { + it('should be able to send and return with body size equal to 16KiB', async () => { + const bodySize = 16 * 1024 + const body = 'HIJK1L2M3N4O5P6Q7R8S9T0UaVbWcXdY'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/body_parser_false', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body).toBeUndefined() + expect(data.rawBody.length).toBe(bodySize) + expect( + data.rawBody.split('HIJK1L2M3N4O5P6Q7R8S9T0UaVbWcXdY').length + ).toBe(bodySize / 32 + 1) + }) + + it('should be able to send and return with body greater than 16KiB', async () => { + const bodySize = 1024 * 1024 + const body = 'JKLM1N2O3P4Q5R6S7T8U9V0WaXbYcZdA'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/body_parser_false', + {}, + { + body, + method: 'POST', + } + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body).toBeUndefined() + expect(data.rawBody.length).toBe(bodySize) + expect( + data.rawBody.split('JKLM1N2O3P4Q5R6S7T8U9V0WaXbYcZdA').length + ).toBe(bodySize / 32 + 1) + }) + }) +}) diff --git a/test/e2e/middleware-general/app/middleware.js b/test/e2e/middleware-general/app/middleware.js new file mode 100644 index 0000000000..54e5b196af --- /dev/null +++ b/test/e2e/middleware-general/app/middleware.js @@ -0,0 +1,273 @@ +/* global globalThis */ +import { NextRequest, NextResponse, URLPattern } from 'next/server' +import magicValue from 'shared-package' + +export const config = { regions: 'auto' } + +const PATTERNS = [ + [ + new URLPattern({ pathname: '/:locale/:id' }), + ({ pathname }) => ({ + pathname: '/:locale/:id', + params: pathname.groups, + }), + ], + [ + new URLPattern({ pathname: '/:id' }), + ({ pathname }) => ({ + pathname: '/:id', + params: pathname.groups, + }), + ], +] + +const params = (url) => { + const input = url.split('?')[0] + let result = {} + + for (const [pattern, handler] of PATTERNS) { + const patternResult = pattern.exec(input) + if (patternResult !== null && 'pathname' in patternResult) { + result = handler(patternResult) + break + } + } + return result +} + +export async function middleware(request) { + const url = request.nextUrl + + if (request.headers.get('x-prerender-revalidate')) { + return NextResponse.next({ + headers: { 'x-middleware': 'hi' }, + }) + } + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname === '/api/edge-search-params') { + const newUrl = url.clone() + newUrl.searchParams.set('foo', 'bar') + return NextResponse.rewrite(newUrl) + } + + if (url.pathname === '/') { + url.pathname = '/ssg/first' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/to-ssg') { + url.pathname = '/ssg/hello' + url.searchParams.set('from', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/sha') { + url.pathname = '/shallow' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/fetch-user-agent-default')) { + try { + const apiRoute = new URL(url) + apiRoute.pathname = '/api/headers' + const res = await fetch(withLocalIp(apiRoute)) + return serializeData(await res.text()) + } catch (err) { + return serializeError(err) + } + } + + if (url.pathname === '/rewrite-to-dynamic') { + url.pathname = '/blog/from-middleware' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-config-rewrite') { + url.pathname = '/rewrite-3' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/fetch-user-agent-crypto')) { + try { + const apiRoute = new URL(url) + apiRoute.pathname = '/api/headers' + const res = await fetch(withLocalIp(apiRoute), { + headers: { + 'user-agent': 'custom-agent', + }, + }) + return serializeData(await res.text()) + } catch (err) { + return serializeError(err) + } + } + + if (url.pathname === '/global') { + // The next line is required to allow to find the env variable + // eslint-disable-next-line no-unused-expressions + process.env.MIDDLEWARE_TEST + + // The next line is required to allow to find the env variable + // eslint-disable-next-line no-unused-expressions + const { ANOTHER_MIDDLEWARE_TEST } = process.env + if (!ANOTHER_MIDDLEWARE_TEST) { + console.log('missing ANOTHER_MIDDLEWARE_TEST') + } + + const { STRING_ENV_VAR: stringEnvVar } = process['env'] + if (!stringEnvVar) { + console.log('missing STRING_ENV_VAR') + } + + return serializeData(JSON.stringify({ process: { env: process.env } })) + } + + if (url.pathname.endsWith('/globalthis')) { + return serializeData(JSON.stringify(Object.keys(globalThis))) + } + + if (url.pathname.endsWith('/webcrypto')) { + const response = {} + try { + const algorithm = { + name: 'RSA-PSS', + hash: 'SHA-256', + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + modulusLength: 2048, + } + const keyUsages = ['sign', 'verify'] + await crypto.subtle.generateKey(algorithm, false, keyUsages) + } catch (err) { + response.error = true + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname.endsWith('/fetch-url')) { + const response = {} + try { + await fetch(new URL('http://localhost')) + } catch (err) { + response.error = { + name: err.name, + message: err.message, + } + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname === '/abort-controller') { + const controller = new AbortController() + const signal = controller.signal + + controller.abort() + const response = {} + + try { + await fetch('https://example.vercel.sh', { signal }) + } catch (err) { + response.error = { + name: err.name, + message: err.message, + } + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname.endsWith('/root-subrequest')) { + const res = await fetch(url) + res.headers.set('x-dynamic-path', 'true') + return res + } + + if (url.pathname === '/about') { + if (magicValue !== 42) throw new Error('shared-package problem') + return NextResponse.rewrite(new URL('/about/a', request.url)) + } + + if (url.pathname === '/redirect-to-somewhere') { + url.pathname = '/somewhere' + return NextResponse.redirect(url, { + headers: { + 'x-redirect-header': 'hi', + }, + }) + } + + if (url.pathname.startsWith('/url')) { + try { + if (request.nextUrl.pathname === '/url/relative-url') { + new URL('/relative') + return Response.next() + } + + if (request.nextUrl.pathname === '/url/relative-request') { + await fetch(new Request('/urls-b')) + return Response.next() + } + + if (request.nextUrl.pathname === '/url/relative-redirect') { + return Response.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-redirect') { + return NextResponse.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-rewrite') { + return NextResponse.rewrite('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-request') { + await fetch(new NextRequest('/urls-b')) + return NextResponse.next() + } + } catch (error) { + return new NextResponse(null, { headers: { error: error.message } }) + } + } + + if (url.pathname === '/ssr-page') { + url.pathname = '/ssr-page-2' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/error-throw' && request.__isData) { + throw new Error('test error') + } + + const original = new URL(request.url) + return NextResponse.next({ + headers: { + 'req-url-path': `${original.pathname}${original.search}`, + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-query': request.nextUrl.searchParams.get('foo'), + 'req-url-locale': request.nextUrl.locale, + 'req-url-params': + url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}', + }, + }) +} + +function serializeData(data) { + return new NextResponse(null, { headers: { data } }) +} + +function serializeError(error) { + return new NextResponse(null, { headers: { error: error.message } }) +} + +function withLocalIp(url) { + return String(url).replace('localhost', '127.0.0.1') +} diff --git a/test/e2e/middleware-general/app/next.config.js b/test/e2e/middleware-general/app/next.config.js new file mode 100644 index 0000000000..9308df9dcb --- /dev/null +++ b/test/e2e/middleware-general/app/next.config.js @@ -0,0 +1,35 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, + redirects() { + return [ + { + source: '/redirect-1', + destination: '/somewhere/else', + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/rewrite-1', + destination: '/ssr-page?from=config', + }, + { + source: '/rewrite-2', + destination: '/about/a?from=next-config', + }, + { + source: '/sha', + destination: '/shallow', + }, + { + source: '/rewrite-3', + destination: '/blog/middleware-rewrite?hello=config', + }, + ] + }, +} diff --git a/test/e2e/middleware-general/app/pages/[id].js b/test/e2e/middleware-general/app/pages/[id].js new file mode 100644 index 0000000000..11f4614a67 --- /dev/null +++ b/test/e2e/middleware-general/app/pages/[id].js @@ -0,0 +1,3 @@ +export default function Index() { + return

Dynamic route

+} diff --git a/test/e2e/middleware-general/app/pages/_app.js b/test/e2e/middleware-general/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/middleware-general/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/middleware-general/app/pages/about/a.js b/test/e2e/middleware-general/app/pages/about/a.js new file mode 100644 index 0000000000..dfacd4eaff --- /dev/null +++ b/test/e2e/middleware-general/app/pages/about/a.js @@ -0,0 +1,7 @@ +export default function AboutA() { + return ( +
+

AboutA

+
+ ) +} diff --git a/test/e2e/middleware-general/app/pages/about/b.js b/test/e2e/middleware-general/app/pages/about/b.js new file mode 100644 index 0000000000..7a44254c45 --- /dev/null +++ b/test/e2e/middleware-general/app/pages/about/b.js @@ -0,0 +1,7 @@ +export default function AboutB() { + return ( +
+

AboutB

+
+ ) +} diff --git a/test/e2e/middleware-general/app/pages/api/edge-search-params.js b/test/e2e/middleware-general/app/pages/api/edge-search-params.js new file mode 100644 index 0000000000..01a968ee9d --- /dev/null +++ b/test/e2e/middleware-general/app/pages/api/edge-search-params.js @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' + +export const config = { runtime: 'experimental-edge', regions: 'default' } + +/** + * @param {import('next/server').NextRequest} + */ +export default (req) => { + return NextResponse.json(Object.fromEntries(req.nextUrl.searchParams)) +} diff --git a/test/e2e/middleware-general/app/pages/api/headers.js b/test/e2e/middleware-general/app/pages/api/headers.js new file mode 100644 index 0000000000..0f65c82e9f --- /dev/null +++ b/test/e2e/middleware-general/app/pages/api/headers.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.json({ url: req.url, headers: req.headers }) +} diff --git a/test/e2e/middleware-general/app/pages/blog/[slug].js b/test/e2e/middleware-general/app/pages/blog/[slug].js new file mode 100644 index 0000000000..590ca0dcc1 --- /dev/null +++ b/test/e2e/middleware-general/app/pages/blog/[slug].js @@ -0,0 +1,23 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} diff --git a/test/e2e/middleware-general/app/pages/error-throw.js b/test/e2e/middleware-general/app/pages/error-throw.js new file mode 100644 index 0000000000..b8a8706e5f --- /dev/null +++ b/test/e2e/middleware-general/app/pages/error-throw.js @@ -0,0 +1,12 @@ +export default function ThrowOnData({ message }) { + return ( +
+

Throw on data request

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/middleware-general/app/pages/error.js b/test/e2e/middleware-general/app/pages/error.js new file mode 100644 index 0000000000..a5a49734cc --- /dev/null +++ b/test/e2e/middleware-general/app/pages/error.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Errors() { + return ( +
+ + Throw on data + +
+ ) +} diff --git a/test/e2e/middleware-general/app/pages/shallow.js b/test/e2e/middleware-general/app/pages/shallow.js new file mode 100644 index 0000000000..4d1c8564ee --- /dev/null +++ b/test/e2e/middleware-general/app/pages/shallow.js @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Shallow({ message }) { + const { pathname, query } = useRouter() + return ( +
+
    +
  • {message}
  • +
  • + + Shallow link to ?hello=world + +
  • +
  • + + Deep link to ?hello=goodbye + +
  • +
  • +

    + Current path: {pathname} +

    +
  • +
  • +

    + Current query: {JSON.stringify(query)} +

    +
  • +
+
+ ) +} + +let i = 0 + +export const getServerSideProps = () => { + return { + props: { + message: `Random: ${++i}${Math.random()}`, + }, + } +} diff --git a/test/e2e/middleware-general/app/pages/ssg/[slug].js b/test/e2e/middleware-general/app/pages/ssg/[slug].js new file mode 100644 index 0000000000..cf9fb1db0b --- /dev/null +++ b/test/e2e/middleware-general/app/pages/ssg/[slug].js @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [asPath, setAsPath] = useState( + router.isReady ? router.asPath : router.href + ) + + useEffect(() => { + if (router.isReady) { + setAsPath(router.asPath) + } + }, [router.asPath, router.isReady]) + + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} + +export function getStaticPaths() { + return { + paths: ['/ssg/first', '/ssg/hello'], + fallback: 'blocking', + } +} diff --git a/test/e2e/middleware-general/app/pages/ssr-page-2.js b/test/e2e/middleware-general/app/pages/ssr-page-2.js new file mode 100644 index 0000000000..67e1b70868 --- /dev/null +++ b/test/e2e/middleware-general/app/pages/ssr-page-2.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Bye Cruel World', + }, + } +} diff --git a/test/e2e/middleware-general/app/pages/ssr-page.js b/test/e2e/middleware-general/app/pages/ssr-page.js new file mode 100644 index 0000000000..e2aaaa56b4 --- /dev/null +++ b/test/e2e/middleware-general/app/pages/ssr-page.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Hello World', + }, + } +} diff --git a/test/e2e/middleware-general/test/index.test.ts b/test/e2e/middleware-general/test/index.test.ts new file mode 100644 index 0000000000..c4a5f4bda3 --- /dev/null +++ b/test/e2e/middleware-general/test/index.test.ts @@ -0,0 +1,677 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, waitFor } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' + +const urlsError = 'Please use only absolute URLs' + +describe('Middleware Runtime', () => { + let next: NextInstance + + const setup = ({ i18n }: { i18n: boolean }) => { + let nextConfigContent = '' + const nextConfigPath = join(__dirname, '../app/next.config.js') + + afterAll(async () => { + await next.destroy() + + if (nextConfigContent) { + await fs.writeFile(nextConfigPath, nextConfigContent) + } + }) + beforeAll(async () => { + if (!i18n) { + nextConfigContent = await fs.readFile(nextConfigPath, 'utf8') + await fs.writeFile( + nextConfigPath, + nextConfigContent.replace('i18n', '__i18n') + ) + } + next = await createNext({ + files: { + 'next.config.js': new FileRef( + join(__dirname, '../app/next.config.js') + ), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + pages: new FileRef(join(__dirname, '../app/pages')), + 'shared-package': new FileRef( + join(__dirname, '../app/node_modules/shared-package') + ), + }, + packageJson: { + scripts: { + setup: `cp -r ./shared-package ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', + }, + }, + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + env: { + ANOTHER_MIDDLEWARE_TEST: 'asdf2', + STRING_ENV_VAR: 'asdf3', + MIDDLEWARE_TEST: 'asdf', + }, + }) + }) + } + + function readMiddlewareJSON(response) { + return JSON.parse(response.headers.get('data')) + } + + function readMiddlewareError(response) { + return response.headers.get('error') + } + + function runTests({ i18n }: { i18n?: boolean }) { + if ((global as any).isNextDev) { + it('refreshes the page when middleware changes ', async () => { + const browser = await webdriver(next.url, `/about`) + await browser.eval('window.didrefresh = "hello"') + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('AboutA') + + const middlewarePath = join(next.testDir, '/middleware.js') + const originalContent = fs.readFileSync(middlewarePath, 'utf-8') + const editedContent = originalContent.replace('/about/a', '/about/b') + + try { + fs.writeFileSync(middlewarePath, editedContent) + await waitFor(1000) + const textb = await browser.elementByCss('h1').text() + expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') + expect(textb).toEqual('AboutB') + } finally { + fs.writeFileSync(middlewarePath, originalContent) + await browser.close() + } + }) + + it('should only contain middleware route in dev middleware manifest', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json` + ) + const matchers = await res.json() + expect(matchers).toEqual([{ regexp: '.*' }]) + }) + } + + if ((global as any).isNextStart) { + it('should have valid middleware field in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + expect(manifest.middleware).toEqual({ + '/': { + env: [ + 'MIDDLEWARE_TEST', + 'ANOTHER_MIDDLEWARE_TEST', + 'STRING_ENV_VAR', + ], + files: expect.arrayContaining([ + 'server/edge-runtime-webpack.js', + 'server/middleware.js', + ]), + name: 'middleware', + page: '/', + matchers: [{ regexp: '^/.*$' }], + wasm: [], + assets: [], + regions: 'auto', + }, + }) + }) + + it('should have the custom config in the manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + + expect(manifest.functions['/api/edge-search-params']).toHaveProperty( + 'regions', + 'default' + ) + }) + + it('should have correct files in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + for (const key of Object.keys(manifest.middleware)) { + const middleware = manifest.middleware[key] + expect(middleware.files).toContainEqual( + expect.stringContaining('server/edge-runtime-webpack') + ) + expect(middleware.files).not.toContainEqual( + expect.stringContaining('static/chunks/') + ) + } + }) + + it('should not run middleware for on-demand revalidate', async () => { + const bypassToken = ( + await fs.readJSON(join(next.testDir, '.next/prerender-manifest.json')) + ).preview.previewModeId + + const res = await fetchViaHTTP(next.url, '/ssg/first', undefined, { + headers: { + 'x-prerender-revalidate': bypassToken, + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-middleware')).toBeFalsy() + expect(res.headers.get('x-nextjs-cache')).toBe('REVALIDATED') + }) + } + + it('passes search params with rewrites', async () => { + const response = await fetchViaHTTP(next.url, `/api/edge-search-params`, { + a: 'b', + }) + await expect(response.json()).resolves.toMatchObject({ + a: 'b', + // included from middleware + foo: 'bar', + }) + }) + + it('should have init header for NextResponse.redirect', async () => { + const res = await fetchViaHTTP( + next.url, + '/redirect-to-somewhere', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere' + ) + expect(res.headers.get('x-redirect-header')).toBe('hi') + }) + + it('should have correct query values for rewrite to ssg page', async () => { + const browser = await webdriver(next.url, '/to-ssg', { + waitHydration: false, + }) + const requests = [] + + browser.on('request', (req) => { + console.error('request', req.url(), req.method()) + if (req.method() === 'HEAD') { + requests.push(req.url()) + } + }) + await browser.eval('window.beforeNav = 1') + + await check(async () => { + const didReq = await browser.eval('next.router.isReady') + return didReq || + requests.some((req) => + new URL(req, 'http://n').pathname.endsWith('/to-ssg.json') + ) + ? 'found' + : JSON.stringify(requests) + }, 'found') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /"slug":"hello"/ + ) + + await check(() => browser.elementByCss('body').text(), /\/to-ssg/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'hello', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'hello', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/ssg/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/to-ssg') + }) + + it('should have correct dynamic route params on client-transition to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/blog/first")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'first', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'first', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/first') + + await browser.eval('window.next.router.push("/blog/second")') + await check(() => browser.elementByCss('body').text(), /"slug":"second"/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'second', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'second', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/second') + }) + + it('should have correct dynamic route params for middleware rewrite to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-to-dynamic")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'from-middleware', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'from-middleware', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-dynamic' + ) + }) + + it('should have correct route params for chained rewrite from middleware to config rewrite', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval( + 'window.next.router.push("/rewrite-to-config-rewrite")' + ) + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-config-rewrite' + ) + }) + + it('should have correct route params for rewrite from config dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-3")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-3') + }) + + it('should have correct route params for rewrite from config non-dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-1")') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Hello World/ + ) + + expect(await browser.eval('window.next.router.query')).toEqual({ + from: 'config', + }) + }) + + it('should redirect the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/redirect-1`, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere/else' + ) + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/redirect-1')`) + await check(async () => { + const pathname = await browser.eval('location.pathname') + return pathname === '/somewhere/else' ? 'success' : pathname + }, 'success') + }) + + it('should rewrite the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-1`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Hello World') + + const browser = await webdriver(next.url, `/`) + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push('/rewrite-1')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('Hello World') ? 'success' : content + }, 'success') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should rewrite correctly for non-SSG/SSP page', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-2`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('AboutA') + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/rewrite-2')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('AboutA') ? 'success' : content + }, 'success') + }) + + it('should respond with 400 on decode failure', async () => { + const res = await fetchViaHTTP(next.url, `/%2`) + expect(res.status).toBe(400) + + if ((global as any).isNextStart) { + expect(await res.text()).toContain('Bad Request') + } + }) + + if (!(global as any).isNextDeploy) { + // user agent differs on Vercel + it('should set fetch user agent correctly', async () => { + const res = await fetchViaHTTP(next.url, `/fetch-user-agent-default`) + + expect(readMiddlewareJSON(res).headers['user-agent']).toBe( + 'Next.js Middleware' + ) + + const res2 = await fetchViaHTTP(next.url, `/fetch-user-agent-crypto`) + expect(readMiddlewareJSON(res2).headers['user-agent']).toBe( + 'custom-agent' + ) + }) + } + + it('should contain process polyfill', async () => { + const res = await fetchViaHTTP(next.url, `/global`) + expect(readMiddlewareJSON(res)).toEqual({ + process: { + env: { + ANOTHER_MIDDLEWARE_TEST: 'asdf2', + STRING_ENV_VAR: 'asdf3', + MIDDLEWARE_TEST: 'asdf', + ...((global as any).isNextDeploy + ? {} + : { + NEXT_RUNTIME: 'edge', + }), + }, + }, + }) + }) + + it(`should contain \`globalThis\``, async () => { + const res = await fetchViaHTTP(next.url, '/globalthis') + expect(readMiddlewareJSON(res).length > 0).toBe(true) + }) + + it(`should contain crypto APIs`, async () => { + const res = await fetchViaHTTP(next.url, '/webcrypto') + expect('error' in readMiddlewareJSON(res)).toBe(false) + }) + + if (!(global as any).isNextDeploy) { + it(`should accept a URL instance for fetch`, async () => { + const response = await fetchViaHTTP(next.url, '/fetch-url') + // TODO: why is an error expected here if it should work? + const { error } = readMiddlewareJSON(response) + expect(error).toBeTruthy() + expect(error.message).not.toContain("Failed to construct 'URL'") + }) + } + + it(`should allow to abort a fetch request`, async () => { + const response = await fetchViaHTTP(next.url, '/abort-controller') + const payload = readMiddlewareJSON(response) + expect('error' in payload).toBe(true) + expect(payload.error.name).toBe('AbortError') + expect(payload.error.message).toContain('The operation was aborted') + }) + + it(`should validate & parse request url from any route`, async () => { + const res = await fetchViaHTTP(next.url, `/static`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/static') + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe(undefined) + expect(params).toEqual(undefined) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + }) + + if (i18n) { + it(`should validate & parse request url from a dynamic route with params`, async () => { + const res = await fetchViaHTTP(next.url, `/fr/1`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/1') + + const { pathname, params } = JSON.parse( + res.headers.get('req-url-params') + ) + expect(pathname).toBe('/:locale/:id') + expect(params).toEqual({ locale: 'fr', id: '1' }) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + + it(`should validate & parse request url from a dynamic route with params and no query`, async () => { + const res = await fetchViaHTTP(next.url, `/fr/abc123`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() + + const { pathname, params } = JSON.parse( + res.headers.get('req-url-params') + ) + expect(pathname).toBe('/:locale/:id') + expect(params).toEqual({ locale: 'fr', id: 'abc123' }) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + } + + it(`should validate & parse request url from a dynamic route with params and query`, async () => { + const res = await fetchViaHTTP(next.url, `/abc123?foo=bar`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + + expect(pathname).toBe('/:id') + expect(params).toEqual({ id: 'abc123' }) + + expect(res.headers.get('req-url-query')).toBe('bar') + + if (i18n) { + expect(res.headers.get('req-url-locale')).toBe('en') + } + }) + + it('should throw when using URL with a relative URL', async () => { + const res = await fetchViaHTTP(next.url, `/url/relative-url`) + expect(readMiddlewareError(res)).toContain('Invalid URL') + }) + + it('should throw when using NextRequest with a relative URL', async () => { + const response = await fetchViaHTTP( + next.url, + `/url/relative-next-request` + ) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + if (!(global as any).isNextDeploy) { + // these errors differ on Vercel + it('should throw when using Request with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-request`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should warn when using Response.redirect with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-redirect`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + } + + it('should warn when using NextResponse.redirect with a relative URL', async () => { + const response = await fetchViaHTTP( + next.url, + `/url/relative-next-redirect` + ) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should throw when using NextResponse.rewrite with a relative URL', async () => { + const response = await fetchViaHTTP( + next.url, + `/url/relative-next-rewrite` + ) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should trigger middleware for data requests', async () => { + const browser = await webdriver(next.url, `/ssr-page`) + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('Bye Cruel World') + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}${i18n ? '/en' : ''}/ssr-page.json` + ) + const json = await res.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + }) + + it('should normalize data requests into page requests', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}${i18n ? '/en' : ''}/send-url.json` + ) + expect(res.headers.get('req-url-path')).toEqual('/send-url') + }) + + it('should keep non data requests in their original shape', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + expect(res.headers.get('req-url-path')).toEqual( + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + }) + + it('should add a rewrite header on data requests for rewrites', async () => { + const res = await fetchViaHTTP(next.url, `/ssr-page`) + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}${i18n ? '/en' : ''}/ssr-page.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + const json = await dataRes.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + expect(res.headers.get('x-nextjs-matched-path')).toBeNull() + expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( + `${i18n ? '/en' : ''}/ssr-page-2` + ) + }) + + it(`hard-navigates when the data request failed`, async () => { + const browser = await webdriver(next.url, `/error`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#throw-on-data').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBeUndefined() + }) + + it('allows shallow linking with middleware', async () => { + const browser = await webdriver(next.url, '/sha') + const getMessageContents = () => + browser.elementById('message-contents').text() + const ssrMessage = await getMessageContents() + const requests: string[] = [] + + browser.on('request', (x) => { + requests.push(x.url()) + }) + + browser.elementById('deep-link').click() + browser.waitForElementByCss('[data-query-hello="goodbye"]') + const deepLinkMessage = await getMessageContents() + expect(deepLinkMessage).not.toEqual(ssrMessage) + + // Changing the route with a shallow link should not cause a server request + browser.elementById('shallow-link').click() + browser.waitForElementByCss('[data-query-hello="world"]') + expect(await getMessageContents()).toEqual(deepLinkMessage) + + // Check that no server requests were made to ?hello=world, + // as it's a shallow request. + expect(requests).toEqual([ + `${next.url}/_next/data/${next.buildId}${ + i18n ? '/en' : '' + }/sha.json?hello=goodbye`, + ]) + }) + } + describe('with i18n', () => { + setup({ i18n: true }) + runTests({ i18n: true }) + }) + + describe('without i18n', () => { + setup({ i18n: false }) + runTests({ i18n: false }) + }) +}) diff --git a/test/e2e/middleware-matcher/app/middleware.js b/test/e2e/middleware-matcher/app/middleware.js new file mode 100644 index 0000000000..546a09b541 --- /dev/null +++ b/test/e2e/middleware-matcher/app/middleware.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export const config = { + matcher: [ + '/', + '/with-middleware/:path*', + '/another-middleware/:path*', + // the below is testing special characters don't break the build + '/_sites/:path((?![^/]*\\.json$)[^/]+$)', + ], +} + +export default (req) => { + const res = NextResponse.next() + res.headers.set('X-From-Middleware', 'true') + return res +} diff --git a/test/e2e/middleware-matcher/app/pages/another-middleware.js b/test/e2e/middleware-matcher/app/pages/another-middleware.js new file mode 100644 index 0000000000..57d698e054 --- /dev/null +++ b/test/e2e/middleware-matcher/app/pages/another-middleware.js @@ -0,0 +1,22 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

This should also run the middleware

+

{JSON.stringify(props)}

+ + to / + +
+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, magnificent world.', + }, + } +} diff --git a/test/e2e/middleware-matcher/app/pages/blog/[slug].js b/test/e2e/middleware-matcher/app/pages/blog/[slug].js new file mode 100644 index 0000000000..f718a7daf6 --- /dev/null +++ b/test/e2e/middleware-matcher/app/pages/blog/[slug].js @@ -0,0 +1,27 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

This should not run the middleware

+

{JSON.stringify(props)}

+ + to /another-middleware + +
+ + to /blog/slug-2 + +
+
+ ) +} + +export const getServerSideProps = ({ params }) => { + return { + props: { + params, + message: 'Hello, magnificent world.', + }, + } +} diff --git a/test/e2e/middleware-matcher/app/pages/index.js b/test/e2e/middleware-matcher/app/pages/index.js new file mode 100644 index 0000000000..5a99c14917 --- /dev/null +++ b/test/e2e/middleware-matcher/app/pages/index.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

root page

+

{JSON.stringify(props)}

+ + to /another-middleware + +
+ + to /blog/slug-1 + +
+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, world.', + }, + } +} diff --git a/test/e2e/middleware-matcher/app/pages/with-middleware.js b/test/e2e/middleware-matcher/app/pages/with-middleware.js new file mode 100644 index 0000000000..da078df25f --- /dev/null +++ b/test/e2e/middleware-matcher/app/pages/with-middleware.js @@ -0,0 +1,16 @@ +export default function Page({ message }) { + return ( +
+

This should run the middleware

+

{message}

+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, cruel world.', + }, + } +} diff --git a/test/e2e/middleware-matcher/index.test.ts b/test/e2e/middleware-matcher/index.test.ts new file mode 100644 index 0000000000..2049c560eb --- /dev/null +++ b/test/e2e/middleware-matcher/index.test.ts @@ -0,0 +1,530 @@ +/* eslint-disable jest/no-identical-title */ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +describe('Middleware can set the matcher in its config', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('does add the header for root request', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain('root page') + }) + + it('adds the header for a matched path', async () => { + const response = await fetchViaHTTP(next.url, '/with-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain('This should run the middleware') + }) + + it('adds the header for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/with-middleware.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, cruel world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/with-middleware.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, cruel world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for another matched path', async () => { + const response = await fetchViaHTTP(next.url, '/another-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain( + 'This should also run the middleware' + ) + }) + + it('adds the header for another matched data path', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/another-middleware.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, magnificent world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does add the header for root data request', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('should load matches in client matchers correctly', async () => { + const browser = await webdriver(next.url, '/') + + await check(async () => { + const matchers = await browser.eval( + (global as any).isNextDev + ? 'window.__DEV_MIDDLEWARE_MATCHERS' + : 'window.__MIDDLEWARE_MATCHERS' + ) + + return matchers && + matchers.some((m) => m.regexp.includes('with-middleware')) && + matchers.some((m) => m.regexp.includes('another-middleware')) + ? 'success' + : 'failed' + }, 'success') + }) + + it('should navigate correctly with matchers', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-another-middleware').click() + await browser.waitForElementByCss('#another-middleware') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + }) + + await browser.elementByCss('#to-index').click() + await browser.waitForElementByCss('#index') + + await browser.elementByCss('#to-blog-slug-1').click() + await browser.waitForElementByCss('#blog') + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + params: { + slug: 'slug-1', + }, + }) + + await browser.elementByCss('#to-blog-slug-2').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /"slug":"slug-2"/ + ) + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + params: { + slug: 'slug-2', + }, + }) + }) +}) + +describe('using a single matcher', () => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

root page

+

{message}

+
+ } + + export const getServerSideProps = ({ params }) => { + return { + props: { + message: "Hello from /" + params.route.join("/") + } + } + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { + matcher: '/middleware/works' + }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('does not add the header for root request', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.headers.get('X-From-Middleware')).toBeFalsy() + }) + + it('does not add the header for root data request', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(response.headers.get('X-From-Middleware')).toBeFalsy() + }) + + it('adds the header for a matched path', async () => { + const response = await fetchViaHTTP(next.url, '/middleware/works') + expect(await response.text()).toContain('Hello from /middleware/works') + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the headers for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello from /middleware/works', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello from /middleware/works', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does not add the header for an unmatched path', async () => { + const response = await fetchViaHTTP(next.url, '/about/me') + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) +}) + +describe('using root matcher', () => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export function getStaticProps() { + return { + props: { + message: 'hello world' + } + } + } + + export default function Home({ message }) { + return
Hi there!
+ } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + + export const config = { matcher: '/' }; + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('adds the header to the /', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.status).toBe(200) + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-from-middleware': 'true', + }) + }) + + it('adds the header to the /index', async () => { + const response = await fetchViaHTTP(next.url, '/index') + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-from-middleware': 'true', + }) + }) + + it('adds the header for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'hello world', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'hello world', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) +}) + +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])('using a single matcher with i18n$title', ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page({ message }) { + return
+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` } + }) + `, + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

catchall page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` + params.route.join("/") } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { matcher: '/' }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it(`adds the header for a matched path`, async () => { + const res1 = await fetchViaHTTP(next.url, `/`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/es`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a mathed root path with /index', async () => { + const res1 = await fetchViaHTTP(next.url, `/index`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/es/index`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`adds the headers for a matched data path`, async () => { + const res1 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { message: `(en) Hello from /` }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es.json`, + undefined + ) + expect(await res2.json()).toMatchObject({ + pageProps: { message: `(es) Hello from /` }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`does not add the header for an unmatched path`, async () => { + const response = await fetchViaHTTP(next.url, `/about/me`) + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) +}) + +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])( + 'using a single matcher with i18n and basePath$title', + ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page({ message }) { + return
+

root page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` } + }) + `, + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

catchall page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` + params.route.join("/") } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { matcher: '/' }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + basePath: '/root', + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it(`adds the header for a matched path`, async () => { + const res1 = await fetchViaHTTP(next.url, `/root`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/root/es`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a mathed root path with /index', async () => { + const res1 = await fetchViaHTTP(next.url, `/root/index`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/root/es/index`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`adds the headers for a matched data path`, async () => { + const res1 = await fetchViaHTTP( + next.url, + `/root/_next/data/${next.buildId}/en.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { message: `(en) Hello from /` }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP( + next.url, + `/root/_next/data/${next.buildId}/es.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res2.json()).toMatchObject({ + pageProps: { message: `(es) Hello from /` }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`does not add the header for an unmatched path`, async () => { + const response = await fetchViaHTTP(next.url, `/root/about/me`) + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) + } +) diff --git a/test/e2e/middleware-redirects/app/middleware.js b/test/e2e/middleware-redirects/app/middleware.js new file mode 100644 index 0000000000..392e8f98e7 --- /dev/null +++ b/test/e2e/middleware-redirects/app/middleware.js @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname === '/old-home') { + if (url.searchParams.get('override') === 'external') { + return Response.redirect('https://example.vercel.sh') + } else { + url.pathname = '/new-home' + return Response.redirect(url) + } + } + + if (url.searchParams.get('foo') === 'bar') { + url.pathname = '/new-home' + url.searchParams.delete('foo') + return Response.redirect(url) + } + + // Chained redirects + if (url.pathname === '/redirect-me-alot') { + url.pathname = '/redirect-me-alot-2' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-2') { + url.pathname = '/redirect-me-alot-3' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-3') { + url.pathname = '/redirect-me-alot-4' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-4') { + url.pathname = '/redirect-me-alot-5' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-5') { + url.pathname = '/redirect-me-alot-6' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-6') { + url.pathname = '/redirect-me-alot-7' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-7') { + url.pathname = '/new-home' + return Response.redirect(url) + } + + // Infinite loop + if (url.pathname === '/infinite-loop') { + url.pathname = '/infinite-loop-1' + return Response.redirect(url) + } + + if (url.pathname === '/infinite-loop-1') { + url.pathname = '/infinite-loop' + return Response.redirect(url) + } + + if (url.pathname === '/to') { + url.pathname = url.searchParams.get('pathname') + url.searchParams.delete('pathname') + return Response.redirect(url) + } + + if (url.pathname === '/with-fragment') { + console.log(String(new URL('/new-home#fragment', url))) + return Response.redirect(new URL('/new-home#fragment', url)) + } +} diff --git a/test/e2e/middleware-redirects/app/next.config.js b/test/e2e/middleware-redirects/app/next.config.js new file mode 100644 index 0000000000..4cc6a5bfab --- /dev/null +++ b/test/e2e/middleware-redirects/app/next.config.js @@ -0,0 +1,15 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl', 'es'], + defaultLocale: 'en', + }, + redirects() { + return [ + { + source: '/to-new', + destination: '/dynamic/new', + permanent: false, + }, + ] + }, +} diff --git a/test/e2e/middleware-redirects/app/pages/_app.js b/test/e2e/middleware-redirects/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/middleware-redirects/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/middleware-redirects/app/pages/api/ok.js b/test/e2e/middleware-redirects/app/pages/api/ok.js new file mode 100644 index 0000000000..fb91e8b611 --- /dev/null +++ b/test/e2e/middleware-redirects/app/pages/api/ok.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.send('ok') +} diff --git a/test/e2e/middleware-redirects/app/pages/dynamic/[slug].js b/test/e2e/middleware-redirects/app/pages/dynamic/[slug].js new file mode 100644 index 0000000000..24df8674ee --- /dev/null +++ b/test/e2e/middleware-redirects/app/pages/dynamic/[slug].js @@ -0,0 +1,13 @@ +export default function Account() { + return ( +

+ Welcome to a /dynamic/[slug] +

+ ) +} + +export function getServerSideProps() { + return { + props: {}, + } +} diff --git a/test/e2e/middleware-redirects/app/pages/index.js b/test/e2e/middleware-redirects/app/pages/index.js new file mode 100644 index 0000000000..0ca12beb09 --- /dev/null +++ b/test/e2e/middleware-redirects/app/pages/index.js @@ -0,0 +1,43 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+

Home Page

+ + Redirect me to a new version of a page + +
+ + Redirect me to an external site + +
+ Redirect me with URL params intact +
+ + Redirect me to Google (with no body response) + +
+ + Redirect me to Google (with no stream response) + +
+ Redirect me alot (chained requests) +
+ Redirect me alot (infinite loop) +
+ + Redirect me to api with locale + +
+ + Redirect me to a redirecting page of new version of page + +
+
+ ) +} diff --git a/test/e2e/middleware-redirects/app/pages/new-home.js b/test/e2e/middleware-redirects/app/pages/new-home.js new file mode 100644 index 0000000000..313011766e --- /dev/null +++ b/test/e2e/middleware-redirects/app/pages/new-home.js @@ -0,0 +1,7 @@ +export default function Account() { + return ( +

+ Welcome to a new page +

+ ) +} diff --git a/test/e2e/middleware-redirects/test/index.test.ts b/test/e2e/middleware-redirects/test/index.test.ts new file mode 100644 index 0000000000..2e780a04fc --- /dev/null +++ b/test/e2e/middleware-redirects/test/index.test.ts @@ -0,0 +1,178 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { check, fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Redirect', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + function tests() { + it('should redirect correctly with redirect in next.config.js', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/to-new")') + await browser.waitForElementByCss('#dynamic') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('does not include the locale in redirects by default', async () => { + const res = await fetchViaHTTP(next.url, `/old-home`, undefined, { + redirect: 'manual', + }) + expect(res.headers.get('location')?.endsWith('/default/about')).toEqual( + false + ) + }) + + it(`should redirect to data urls with data requests and internal redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + + expect( + res.headers + .get('x-nextjs-redirect') + ?.endsWith(`/es/new-home?override=internal`) + ).toEqual(true) + expect(res.headers.get('location')).toEqual(null) + }) + + it(`should redirect to external urls with data requests and external redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'external' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + + expect(res.headers.get('x-nextjs-redirect')).toEqual( + 'https://example.vercel.sh/' + ) + expect(res.headers.get('location')).toEqual(null) + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#old-home-external').click() + await check(async () => { + expect(await browser.elementByCss('h1').text()).toEqual( + 'Example Domain' + ) + return 'yes' + }, 'yes') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should redirect`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/old-home`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/old-home`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should implement internal redirects`, async () => { + const browser = await webdriver(next.url, `${locale}`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#old-home').click() + await browser.waitForElementByCss('#new-home-title') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect cleanly with the original url param`, async () => { + const browser = await webdriver(next.url, `${locale}/blank-page?foo=bar`) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(`${locale}/new-home`) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect multiple times`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/redirect-me-alot`) + const browser = await webdriver(next.url, `${locale}/redirect-me-alot`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should redirect (infinite-loop)`, async () => { + await expect( + fetchViaHTTP(next.url, `${locale}/infinite-loop`) + ).rejects.toThrow() + }) + + it(`${label}should redirect to api route with locale`, async () => { + const browser = await webdriver(next.url, `${locale}`) + await browser.elementByCss('#link-to-api-with-locale').click() + await browser.waitForCondition('window.location.pathname === "/api/ok"') + await check(() => browser.elementByCss('body').text(), 'ok') + const logs = await browser.log() + const errors = logs + .filter((x) => x.source === 'error') + .map((x) => x.message) + .join('\n') + expect(errors).not.toContain('Failed to lookup route') + }) + + // A regression test for https://github.com/vercel/next.js/pull/41501 + it(`${label}should redirect with a fragment`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/with-fragment`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/with-fragment`) + try { + expect(await browser.eval(`window.location.hash`)).toBe(`#fragment`) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('Welcome to a new page') + }) + } + tests() + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/middleware-request-header-overrides/app/.gitignore b/test/e2e/middleware-request-header-overrides/app/.gitignore new file mode 100644 index 0000000000..e985853ed8 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/test/e2e/middleware-request-header-overrides/app/middleware.js b/test/e2e/middleware-request-header-overrides/app/middleware.js new file mode 100644 index 0000000000..4421a4f37f --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/middleware.js @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const headers = new Headers(request.headers) + headers.set('x-from-middleware', 'hello-from-middleware') + + const removeHeaders = request.nextUrl.searchParams.get('remove-headers') + if (removeHeaders) { + for (const key of removeHeaders.split(',')) { + headers.delete(key) + } + } + + const updateHeader = request.nextUrl.searchParams.get('update-headers') + if (updateHeader) { + for (const kv of updateHeader.split(',')) { + const [key, value] = kv.split('=') + headers.set(key, value) + } + } + + return NextResponse.next({ + request: { + headers, + }, + }) +} diff --git a/test/e2e/middleware-request-header-overrides/app/next.config.js b/test/e2e/middleware-request-header-overrides/app/next.config.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js new file mode 100644 index 0000000000..0ece8ea2c7 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js @@ -0,0 +1,11 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default (req) => { + return Response.json(Object.fromEntries(req.headers.entries()), { + headers: { + 'headers-from-edge-function': '1', + }, + }) +} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js new file mode 100644 index 0000000000..0f1a9262d9 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js @@ -0,0 +1,6 @@ +export default (req, res) => { + return res + .status(200) + .setHeader('headers-from-serverless', '1') + .json(req.headers) +} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js b/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js new file mode 100644 index 0000000000..ed2e4a6fcc --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js @@ -0,0 +1,15 @@ +export default function SSRPage({ headers }) { + return ( + <> +

{JSON.stringify(headers)}

+ + ) +} + +export const getServerSideProps = (ctx) => { + return { + props: { + headers: ctx.req.headers, + }, + } +} diff --git a/test/e2e/middleware-request-header-overrides/test/index.test.ts b/test/e2e/middleware-request-header-overrides/test/index.test.ts new file mode 100644 index 0000000000..03f7296b5b --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/test/index.test.ts @@ -0,0 +1,119 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import cheerio from 'cheerio' + +describe('Middleware Request Headers Overrides', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + describe.each([ + { + title: 'Serverless Functions', + path: '/api/dump-headers-serverless', + toJson: (res: Response) => res.json(), + }, + { + title: 'Edge Functions', + path: '/api/dump-headers-edge', + toJson: (res: Response) => res.json(), + }, + { + title: 'getServerSideProps', + path: '/ssr-page', + toJson: async (res: Response) => { + const $ = cheerio.load(await res.text()) + return JSON.parse($('#headers').text()) + }, + }, + ])('$title Backend', ({ path, toJson }) => { + it(`Adds new headers`, async () => { + const res = await fetchViaHTTP(next.url, path, null, { + headers: { + 'x-from-client': 'hello-from-client', + }, + }) + expect(await toJson(res)).toMatchObject({ + 'x-from-client': 'hello-from-client', + 'x-from-middleware': 'hello-from-middleware', + }) + }) + + it(`Deletes headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'remove-headers': 'x-from-client1,x-from-client2', + }, + { + headers: { + 'x-from-client1': 'hello-from-client', + 'X-From-Client2': 'hello-from-client', + }, + } + ) + + const json = await toJson(res) + expect(json).not.toHaveProperty('x-from-client1') + expect(json).not.toHaveProperty('X-From-Client2') + expect(json).toMatchObject({ + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + }) + + it(`Updates headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'update-headers': + 'x-from-client1=new-value1,x-from-client2=new-value2', + }, + { + headers: { + 'x-from-client1': 'old-value1', + 'X-From-Client2': 'old-value2', + 'x-from-client3': 'old-value3', + }, + } + ) + expect(await toJson(res)).toMatchObject({ + 'x-from-client1': 'new-value1', + 'x-from-client2': 'new-value2', + 'x-from-client3': 'old-value3', + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client3')).toBeNull() + }) + }) +}) diff --git a/test/e2e/middleware-responses/app/middleware.js b/test/e2e/middleware-responses/app/middleware.js new file mode 100644 index 0000000000..abc81bbe91 --- /dev/null +++ b/test/e2e/middleware-responses/app/middleware.js @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server' + +// we use this trick to fool static analysis at build time, so we can build a +// middleware that will return a body at run time, and check it is disallowed. +class MyResponse extends Response {} + +export async function middleware(request, ev) { + // eslint-disable-next-line no-undef + const { readable, writable } = new TransformStream() + const url = request.nextUrl + const writer = writable.getWriter() + const encoder = new TextEncoder() + const next = NextResponse.next() + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + // Header based on query param + if (url.searchParams.get('nested-header') === 'true') { + next.headers.set('x-nested-header', 'valid') + } + + // Ensure deep can append to this value + if (url.searchParams.get('append-me') === 'true') { + next.headers.append('x-append-me', 'top') + } + + // Ensure deep can append to this value + if (url.searchParams.get('cookie-me') === 'true') { + next.headers.append('set-cookie', 'bar=chocochip') + } + + // Sends a header + if (url.pathname === '/header') { + next.headers.set('x-first-header', 'valid') + return next + } + + if (url.pathname === '/two-cookies') { + const headers = new Headers() + headers.append('set-cookie', 'foo=chocochip') + headers.append('set-cookie', 'bar=chocochip') + return new Response(null, { headers }) + } + + // Streams a basic response + if (url.pathname === '/stream-a-response') { + ev.waitUntil( + (async () => { + writer.write(encoder.encode('this is a streamed ')) + writer.write(encoder.encode('response ')) + writer.close() + })() + ) + + return new MyResponse(readable) + } + + if (url.pathname === '/bad-status') { + return new Response(null, { + headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, + status: 401, + }) + } + + // Sends response + if (url.pathname === '/send-response') { + return new MyResponse(JSON.stringify({ message: 'hi!' })) + } + + return next +} diff --git a/test/e2e/middleware-responses/app/next.config.js b/test/e2e/middleware-responses/app/next.config.js new file mode 100644 index 0000000000..f548199a3b --- /dev/null +++ b/test/e2e/middleware-responses/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/middleware-responses/app/pages/index.js b/test/e2e/middleware-responses/app/pages/index.js new file mode 100644 index 0000000000..e931b33f81 --- /dev/null +++ b/test/e2e/middleware-responses/app/pages/index.js @@ -0,0 +1,48 @@ +import Link from 'next/link' + +export default function Home({ message }) { + return ( +
+

Hello {message}

+ Stream a response +
+ Stream a long response + Test streaming after response ends +
+ + Attempt to add a header after stream ends + +
+ + Redirect to Google and attempt to stream after + +
+ Respond with a header +
+ + Respond with 2 headers (nested middleware effect) + +
+ Respond with body, end, set a header +
+ + Respond with body, end, send another body + +
+ Respond with body +
+ Redirect and then send a body +
+ Send React component as a body +
+ Stream React component +
+ 404 +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || 'World' }, +}) diff --git a/test/e2e/middleware-responses/test/index.test.ts b/test/e2e/middleware-responses/test/index.test.ts new file mode 100644 index 0000000000..6fd4bcd962 --- /dev/null +++ b/test/e2e/middleware-responses/test/index.test.ts @@ -0,0 +1,89 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Responses', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}responds with multiple cookies`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/two-cookies`) + expect(res.headers.raw()['set-cookie']).toEqual([ + 'foo=chocochip', + 'bar=chocochip', + ]) + }) + + it(`${label}should fail when returning a stream`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/stream-a-response`) + expect(res.status).toBe(500) + + if (!(global as any).isNextDeploy) { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should fail when returning a text body`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/send-response`) + expect(res.status).toBe(500) + + if (!(global as any).isNextDeploy) { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should respond with a 401 status code`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/bad-status`) + const html = await res.text() + expect(res.status).toBe(401) + expect(html).toBe('') + }) + + it(`${label}should respond with one header`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/header`) + expect(res.headers.get('x-first-header')).toBe('valid') + }) + + it(`${label}should respond with two headers`, async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/header?nested-header=true` + ) + expect(res.headers.get('x-first-header')).toBe('valid') + expect(res.headers.get('x-nested-header')).toBe('valid') + }) + + it(`${label}should respond appending headers headers`, async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/?nested-header=true&append-me=true&cookie-me=true` + ) + expect(res.headers.get('x-nested-header')).toBe('valid') + expect(res.headers.get('x-append-me')).toBe('top') + expect(res.headers.raw()['set-cookie']).toEqual(['bar=chocochip']) + }) + } + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/middleware-rewrites/app/middleware.js b/test/e2e/middleware-rewrites/app/middleware.js new file mode 100644 index 0000000000..d8c89b3292 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/middleware.js @@ -0,0 +1,135 @@ +import { NextResponse } from 'next/server' + +const PUBLIC_FILE = /\.(.*)$/ + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname.includes('/to/some/404/path')) { + return NextResponse.next({ + 'x-matched-path': '/404', + }) + } + + if (url.pathname.includes('/fallback-true-blog/rewritten')) { + request.nextUrl.pathname = '/about' + return NextResponse.rewrite(request.nextUrl) + } + + if (url.pathname.startsWith('/about') && url.searchParams.has('override')) { + const isExternal = url.searchParams.get('override') === 'external' + return NextResponse.rewrite( + isExternal + ? 'https://example.vercel.sh' + : new URL('/ab-test/a', request.url) + ) + } + + if (url.pathname === '/rewrite-to-beforefiles-rewrite') { + url.pathname = '/beforefiles-rewrite' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-afterfiles-rewrite') { + url.pathname = '/afterfiles-rewrite' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/to-blog')) { + const slug = url.pathname.split('/').pop() + url.pathname = `/fallback-true-blog/${slug}` + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-ab-test') { + let bucket = request.cookies.get('bucket') + if (!bucket) { + bucket = Math.random() >= 0.5 ? 'a' : 'b' + url.pathname = `/ab-test/${bucket}` + const response = NextResponse.rewrite(url) + response.cookies.set('bucket', bucket, { maxAge: 10 }) + return response + } + + url.pathname = `/${bucket}` + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-me-to-about') { + url.pathname = '/about' + return NextResponse.rewrite(url, { + headers: { 'x-rewrite-target': String(url) }, + }) + } + + if (url.pathname === '/rewrite-me-with-a-colon') { + url.pathname = '/with:colon' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/colon:here') { + url.pathname = '/no-colon-here' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-me-to-vercel') { + return NextResponse.rewrite('https://example.vercel.sh') + } + + if (url.pathname === '/clear-query-params') { + const allowedKeys = ['allowed'] + for (const key of [...url.searchParams.keys()]) { + if (!allowedKeys.includes(key)) { + url.searchParams.delete(key) + } + } + return NextResponse.rewrite(url) + } + + if ( + url.pathname === '/rewrite-me-without-hard-navigation' || + url.searchParams.get('path') === 'rewrite-me-without-hard-navigation' + ) { + url.searchParams.set('middleware', 'foo') + url.pathname = request.cookies.has('about-bypass') + ? '/about-bypass' + : '/about' + + return NextResponse.rewrite(url, { + headers: { 'x-middleware-cache': 'no-cache' }, + }) + } + + if (url.pathname.endsWith('/dynamic-replace')) { + url.pathname = '/dynamic-fallback/catch-all' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/country')) { + const locale = url.searchParams.get('my-locale') + if (locale) { + url.locale = locale + } + + const country = url.searchParams.get('country') || 'us' + if (!PUBLIC_FILE.test(url.pathname) && !url.pathname.includes('/api/')) { + url.pathname = `/country/${country}` + return NextResponse.rewrite(url) + } + } + + if (url.pathname.startsWith('/i18n')) { + url.searchParams.set('locale', url.locale) + return NextResponse.rewrite(url) + } + + return NextResponse.rewrite(request.nextUrl) +} diff --git a/test/e2e/middleware-rewrites/app/next.config.js b/test/e2e/middleware-rewrites/app/next.config.js new file mode 100644 index 0000000000..32c5d38016 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/next.config.js @@ -0,0 +1,27 @@ +module.exports = { + i18n: { + locales: ['ja', 'en', 'fr', 'es'], + defaultLocale: 'en', + }, + rewrites() { + return { + beforeFiles: [ + { + source: '/beforefiles-rewrite', + destination: '/ab-test/a', + }, + ], + afterFiles: [ + { + source: '/afterfiles-rewrite', + destination: '/ab-test/b', + }, + { + source: '/afterfiles-rewrite-ssg', + destination: '/fallback-true-blog/first', + }, + ], + fallback: [], + } + }, +} diff --git a/test/e2e/middleware-rewrites/app/pages/404.js b/test/e2e/middleware-rewrites/app/pages/404.js new file mode 100644 index 0000000000..cc3910106c --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/404.js @@ -0,0 +1,3 @@ +export default function NotFound() { + return

custom 404 page

+} diff --git a/test/e2e/middleware-rewrites/app/pages/[param].js b/test/e2e/middleware-rewrites/app/pages/[param].js new file mode 100644 index 0000000000..d33f75dd2d --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/[param].js @@ -0,0 +1,12 @@ +export const getServerSideProps = ({ params, query }) => { + return { props: { params, query } } +} + +export default function Page({ params: { param }, query: { qp } }) { + return ( + <> +

{param}

+

{qp}

+ + ) +} diff --git a/test/e2e/middleware-rewrites/app/pages/_app.js b/test/e2e/middleware-rewrites/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/middleware-rewrites/app/pages/ab-test/a.js b/test/e2e/middleware-rewrites/app/pages/ab-test/a.js new file mode 100644 index 0000000000..3497b535df --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/ab-test/a.js @@ -0,0 +1,9 @@ +export default function Home() { + return

Welcome Page A

+} + +export const getServerSideProps = () => ({ + props: { + abtest: true, + }, +}) diff --git a/test/e2e/middleware-rewrites/app/pages/ab-test/b.js b/test/e2e/middleware-rewrites/app/pages/ab-test/b.js new file mode 100644 index 0000000000..4f15e239e9 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/ab-test/b.js @@ -0,0 +1,9 @@ +export default function Home() { + return

Welcome Page B

+} + +export const getServerSideProps = () => ({ + props: { + abtest: true, + }, +}) diff --git a/test/e2e/middleware-rewrites/app/pages/about-bypass.js b/test/e2e/middleware-rewrites/app/pages/about-bypass.js new file mode 100644 index 0000000000..4039fc76ad --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/about-bypass.js @@ -0,0 +1,12 @@ +export default function AboutBypass({ message }) { + return ( +
+

About Bypassed Page

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/middleware-rewrites/app/pages/about.js b/test/e2e/middleware-rewrites/app/pages/about.js new file mode 100644 index 0000000000..4eff796b31 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/about.js @@ -0,0 +1,16 @@ +export default function Main({ message, middleware }) { + return ( +
+

About Page

+

{message}

+

{middleware}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { + middleware: query.middleware || '', + message: query.message || '', + }, +}) diff --git a/test/e2e/middleware-rewrites/app/pages/clear-query-params.js b/test/e2e/middleware-rewrites/app/pages/clear-query-params.js new file mode 100644 index 0000000000..2901a155e8 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/clear-query-params.js @@ -0,0 +1,12 @@ +export default function ClearQueryParams(props) { + return
{JSON.stringify(props.query)}
+} + +/** @type {import('next').GetServerSideProps} */ +export const getServerSideProps = (req) => { + return { + props: { + query: { ...req.query }, + }, + } +} diff --git a/test/e2e/middleware-rewrites/app/pages/country/[country].js b/test/e2e/middleware-rewrites/app/pages/country/[country].js new file mode 100644 index 0000000000..dd09ad84a7 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/country/[country].js @@ -0,0 +1,22 @@ +export const getStaticPaths = async () => { + return { + fallback: 'blocking', + paths: [], + } +} + +export const getStaticProps = async ({ params: { country }, locale }) => { + return { + props: { country, locale }, + revalidate: false, + } +} + +export default function CountryPage({ locale, country }) { + return ( +
    +
  • {country}
  • +
  • {locale}
  • +
+ ) +} diff --git a/test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js b/test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js new file mode 100644 index 0000000000..7f91f8690f --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js @@ -0,0 +1,5 @@ +const PartsPage = () => { + return
Parts page
+} + +export default PartsPage diff --git a/test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js b/test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js new file mode 100644 index 0000000000..a2aa1488ce --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js @@ -0,0 +1,48 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + if (useRouter().isFallback) { + return

Loading...

+ } + + return ( + <> +

{JSON.stringify(props)}

+ + to /fallback-true-blog/first?hello=world + +
+ + to /fallback-true-blog/second + +
+ + ) +} + +export function getStaticPaths() { + return { + paths: [ + '/fallback-true-blog/first', + '/fallback-true-blog/build-time-1', + '/fallback-true-blog/build-time-2', + '/fallback-true-blog/build-time-3', + '/fallback-true-blog/build-time-4', + ], + fallback: true, + } +} + +export function getStaticProps({ params }) { + return { + props: { + params, + time: Date.now(), + }, + } +} diff --git a/test/e2e/middleware-rewrites/app/pages/i18n.js b/test/e2e/middleware-rewrites/app/pages/i18n.js new file mode 100644 index 0000000000..0c3d2b993d --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/i18n.js @@ -0,0 +1,40 @@ +import Link from 'next/link' + +export default function Home({ locale }) { + return ( +
+
{locale}
+
    +
  • + + Go to en + +
  • +
  • + + Go to en2 + +
  • +
  • + + Go to ja + +
  • +
  • + + Go to ja2 + +
  • +
  • + + Go to fr + +
  • +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { locale: query.locale }, +}) diff --git a/test/e2e/middleware-rewrites/app/pages/index.js b/test/e2e/middleware-rewrites/app/pages/index.js new file mode 100644 index 0000000000..817ddf4077 --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/index.js @@ -0,0 +1,82 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Home() { + const router = useRouter() + return ( +
+

Home Page

+
+ A/B test homepage +
+ + Rewrite me to about + +
+ + Rewrite me to beforeFiles Rewrite + +
+ + Rewrite me to afterFiles Rewrite + +
+ Rewrite me to Vercel +
+ + Redirect me to Vercel (but with double reroutes) + +
+ + Rewrite me without a hard navigation + +
+ + Rewrite me to external site + +
+ + Rewrite me to internal path + +
+ + normal SSG link + + + ) +} + +export function getServerSideProps() { + return { + props: { + now: Date.now(), + }, + } +} diff --git a/test/e2e/middleware-rewrites/app/pages/ssg.js b/test/e2e/middleware-rewrites/app/pages/ssg.js new file mode 100644 index 0000000000..7bdaed70bf --- /dev/null +++ b/test/e2e/middleware-rewrites/app/pages/ssg.js @@ -0,0 +1,14 @@ +export default function Main({ now }) { + return ( +
+

SSG Page

+

{now}

+
+ ) +} + +export const getStaticProps = () => ({ + props: { + now: Date.now(), + }, +}) diff --git a/test/e2e/middleware-rewrites/test/index.test.ts b/test/e2e/middleware-rewrites/test/index.test.ts new file mode 100644 index 0000000000..3ef7219c83 --- /dev/null +++ b/test/e2e/middleware-rewrites/test/index.test.ts @@ -0,0 +1,770 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import escapeStringRegexp from 'escape-string-regexp' + +describe('Middleware Rewrite', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + function tests() { + it('should not have un-necessary data request on rewrite', async () => { + const browser = await webdriver(next.url, '/to-blog/first', { + waitHydration: false, + }) + let requests = [] + + browser.on('request', (req) => { + requests.push(new URL(req.url()).pathname) + }) + + await check( + () => browser.eval(`next.router.isReady ? "yup" : "nope"`), + 'yup' + ) + + expect( + requests.filter((url) => url === '/fallback-true-blog/first.json') + .length + ).toBeLessThan(2) + }) + + it('should not mix component cache when navigating between dynamic routes', async () => { + const browser = await webdriver(next.url, '/param-1') + + expect(await browser.eval('next.router.pathname')).toBe('/[param]') + expect(await browser.eval('next.router.query.param')).toBe('param-1') + + await browser.eval(`next.router.push("/fallback-true-blog/first")`) + await check( + () => browser.eval('next.router.pathname'), + '/fallback-true-blog/[slug]' + ) + expect(await browser.eval('next.router.query.slug')).toBe('first') + expect(await browser.eval('next.router.asPath')).toBe( + '/fallback-true-blog/first' + ) + + await browser.back() + await check(() => browser.eval('next.router.pathname'), '/[param]') + expect(await browser.eval('next.router.query.param')).toBe('param-1') + expect(await browser.eval('next.router.asPath')).toBe('/param-1') + }) + + it('should have props for afterFiles rewrite to SSG page', async () => { + let browser = await webdriver(next.url, '/') + await browser.eval(`next.router.push("/afterfiles-rewrite-ssg")`) + + await check( + () => browser.eval('next.router.isReady ? "yup": "nope"'), + 'yup' + ) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /"slug":"first"/ + ) + + browser = await webdriver(next.url, '/afterfiles-rewrite-ssg') + await check( + () => browser.eval('next.router.isReady ? "yup": "nope"'), + 'yup' + ) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /"slug":"first"/ + ) + }) + + it('should hard navigate on 404 for data request', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push("/to/some/404/path")`) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /custom 404 page/ + ) + expect(await browser.eval('location.pathname')).toBe('/to/some/404/path') + expect(await browser.eval('window.beforeNav')).not.toBe(1) + }) + + // TODO: middleware effect headers aren't available here + it.skip('includes the locale in rewrites by default', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`) + expect( + res.headers.get('x-middleware-rewrite')?.endsWith('/en/about') + ).toEqual(true) + }) + + it('should rewrite correctly when navigating via history', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + + await browser.refresh() + await browser.back() + await browser.waitForElementByCss('#override-with-internal-rewrite') + await browser.forward() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + }) + + it('should rewrite correctly when navigating via history after query update', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + + await browser.refresh() + await browser.waitForCondition(`!!window.next.router.isReady`) + await browser.back() + await browser.waitForElementByCss('#override-with-internal-rewrite') + await browser.forward() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + }) + + it('should return HTML/data correctly for pre-rendered page', async () => { + for (const slug of [ + 'first', + 'build-time-1', + 'build-time-2', + 'build-time-3', + ]) { + const res = await fetchViaHTTP(next.url, `/fallback-true-blog/${slug}`) + expect(res.status).toBe(200) + + const $ = cheerio.load(await res.text()) + expect(JSON.parse($('#props').text())?.params).toEqual({ + slug, + }) + + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en/fallback-true-blog/${slug}.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(dataRes.status).toBe(200) + expect((await dataRes.json())?.pageProps?.params).toEqual({ + slug, + }) + } + }) + + it('should override with rewrite internally correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/about`, + { override: 'internal' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(next.url, ``) + await browser.elementByCss('#override-with-internal-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Welcome Page A/ + ) + expect(await browser.eval('window.location.pathname')).toBe(`/about`) + expect(await browser.eval('window.location.search')).toBe( + '?override=internal' + ) + }) + + it(`should rewrite to data urls for incoming data request internally rewritten`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/about.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + const json = await res.json() + expect(json.pageProps).toEqual({ abtest: true }) + }) + + it('should override with rewrite externally correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/about`, + { override: 'external' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + const browser = await webdriver(next.url, ``) + await browser.elementByCss('#override-with-external-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Example Domain/ + ) + await check(() => browser.eval('window.location.pathname'), `/about`) + await check( + () => browser.eval('window.location.search'), + '?override=external' + ) + }) + + it(`should rewrite to the external url for incoming data request externally rewritten`, async () => { + const browser = await webdriver( + next.url, + `/_next/data/${next.buildId}/es/about.json?override=external`, + undefined + ) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Example Domain/ + ) + }) + + it('should rewrite to fallback: true page successfully', async () => { + const randomSlug = `another-${Date.now()}` + const res2 = await fetchViaHTTP(next.url, `/to-blog/${randomSlug}`) + expect(res2.status).toBe(200) + expect(await res2.text()).toContain('Loading...') + + const randomSlug2 = `another-${Date.now()}` + const browser = await webdriver(next.url, `/to-blog/${randomSlug2}`) + + await check(async () => { + const props = JSON.parse(await browser.elementByCss('#props').text()) + return props.params.slug === randomSlug2 + ? 'success' + : JSON.stringify(props) + }, 'success') + }) + + it('should allow to opt-out prefetch caching', async () => { + const browser = await webdriver(next.url, '/') + await browser.addCookie({ name: 'about-bypass', value: '1' }) + await browser.refresh() + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + await browser.deleteCookies() + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Bypassed Page') + }) + + if (!(global as any).isNextDev) { + it('should not prefetch non-SSG routes', async () => { + const browser = await webdriver(next.url, '/') + + await check(async () => { + const hrefs = await browser.eval( + `Object.keys(window.next.router.sdc)` + ) + for (const url of ['/en/ssg.json']) { + if (!hrefs.some((href) => href.includes(url))) { + return JSON.stringify(hrefs, null, 2) + } + } + return 'yes' + }, 'yes') + }) + } + + it(`should allow to rewrite keeping the locale in pathname`, async () => { + const res = await fetchViaHTTP(next.url, '/fr/country', { + country: 'spain', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('fr') + expect($('#country').text()).toBe('spain') + }) + + it(`should allow to rewrite to a different locale`, async () => { + const res = await fetchViaHTTP(next.url, '/country', { + 'my-locale': 'es', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('es') + expect($('#country').text()).toBe('us') + }) + + it(`should behave consistently on recursive rewrites`, async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`, { + override: 'internal', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + + const browser = await webdriver(next.url, `/`) + await browser.elementByCss('#rewrite-me-to-about').click() + await check( + () => browser.eval(`window.location.pathname`), + `/rewrite-me-to-about` + ) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Page') + }) + + it(`should allow to switch locales`, async () => { + const browser = await webdriver(next.url, '/i18n') + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-ja').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en').click() + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-fr').click() + await browser.waitForElementByCss('.fr') + await browser.elementByCss('#link-ja2').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en2').click() + await browser.waitForElementByCss('.en') + }) + + it('should allow to rewrite to a `beforeFiles` rewrite config', async () => { + const res = await fetchViaHTTP( + next.url, + `/rewrite-to-beforefiles-rewrite` + ) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#rewrite-to-beforefiles-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Welcome Page A/ + ) + expect(await browser.eval('window.location.pathname')).toBe( + `/rewrite-to-beforefiles-rewrite` + ) + }) + + it('should allow to rewrite to a `afterFiles` rewrite config', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-to-afterfiles-rewrite`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page B') + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#rewrite-to-afterfiles-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Welcome Page B/ + ) + expect(await browser.eval('window.location.pathname')).toBe( + `/rewrite-to-afterfiles-rewrite` + ) + }) + + it('should have correct query info for dynamic route after query hydration', async () => { + const browser = await webdriver( + next.url, + '/fallback-true-blog/first?hello=world' + ) + + await check( + () => + browser.eval( + 'next.router.query.hello === "world" ? "success" : JSON.stringify(next.router.query)' + ), + 'success' + ) + + expect(await browser.eval('next.router.query')).toEqual({ + slug: 'first', + hello: 'world', + }) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/first' + ) + expect(await browser.eval('location.search')).toBe('?hello=world') + }) + + it('should handle shallow navigation correctly (non-dynamic page)', async () => { + const browser = await webdriver(next.url, '/about') + const requests = [] + + browser.on('request', (req) => { + const url = req.url() + if (url.includes('_next/data')) requests.push(url) + }) + + await browser.eval( + `next.router.push('/about?hello=world', undefined, { shallow: true })` + ) + await check(() => browser.eval(`next.router.query.hello`), 'world') + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({ hello: 'world' }) + expect(await browser.eval('location.pathname')).toBe('/about') + expect(await browser.eval('location.search')).toBe('?hello=world') + + await browser.eval( + `next.router.push('/about', undefined, { shallow: true })` + ) + await check( + () => browser.eval(`next.router.query.hello || 'empty'`), + 'empty' + ) + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({}) + expect(await browser.eval('location.pathname')).toBe('/about') + expect(await browser.eval('location.search')).toBe('') + }) + + it('should handle shallow navigation correctly (dynamic page)', async () => { + const browser = await webdriver(next.url, '/fallback-true-blog/first') + + await check(async () => { + await browser.elementByCss('#to-query-shallow').click() + return browser.eval('location.search') + }, '?hello=world') + + expect(await browser.eval(`next.router.pathname`)).toBe( + '/fallback-true-blog/[slug]' + ) + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({ hello: 'world', slug: 'first' }) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/first' + ) + expect(await browser.eval('location.search')).toBe('?hello=world') + + await browser.elementByCss('#to-no-query-shallow').click() + await check(() => browser.eval(`next.router.query.slug`), 'second') + + expect(await browser.eval(`next.router.pathname`)).toBe( + '/fallback-true-blog/[slug]' + ) + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({ + slug: 'second', + }) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/second' + ) + expect(await browser.eval('location.search')).toBe('') + }) + + it('should resolve dynamic route after rewrite correctly', async () => { + const browser = await webdriver(next.url, '/fallback-true-blog/first', { + waitHydration: false, + }) + let requests = [] + + browser.on('request', (req) => { + const url = new URL( + req + .url() + .replace(new RegExp(escapeStringRegexp(next.buildId)), 'BUILD_ID') + ).pathname + + if (url.includes('_next/data')) requests.push(url) + }) + + // wait for initial query update request + await check(async () => { + const didReq = await browser.eval('next.router.isReady') + if (requests.length > 0 || didReq) { + requests = [] + return 'yup' + } + }, 'yup') + + expect(await browser.eval(`next.router.pathname`)).toBe( + '/fallback-true-blog/[slug]' + ) + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({ + slug: 'first', + }) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/first' + ) + expect(await browser.eval('location.search')).toBe('') + + await browser.eval(`next.router.push('/fallback-true-blog/rewritten')`) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /About Page/ + ) + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({}) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/rewritten' + ) + expect(await browser.eval('location.search')).toBe('') + expect( + requests.some( + (req) => + req === `/_next/data/BUILD_ID/en/fallback-true-blog/rewritten.json` + ) + ).toBe(true) + + await browser.eval(`next.router.push('/fallback-true-blog/second')`) + await check( + () => browser.eval(`next.router.pathname`), + '/fallback-true-blog/[slug]' + ) + + expect(await browser.eval(`next.router.pathname`)).toBe( + '/fallback-true-blog/[slug]' + ) + expect( + JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`)) + ).toEqual({ + slug: 'second', + }) + expect(await browser.eval('location.pathname')).toBe( + '/fallback-true-blog/second' + ) + expect(await browser.eval('location.search')).toBe('') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + function getCookieFromResponse(res, cookieName) { + // node-fetch bundles the cookies as string in the Response + const cookieArray = res.headers.raw()['set-cookie'] + for (const cookie of cookieArray) { + let individualCookieParams = cookie.split(';') + let individualCookie = individualCookieParams[0].split('=') + if (individualCookie[0] === cookieName) { + return individualCookie[1] + } + } + return -1 + } + + it(`${label}should add a cookie and rewrite to a/b test`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-to-ab-test`) + const html = await res.text() + const $ = cheerio.load(html) + // Set-Cookie header with Expires should not be split into two + expect(res.headers.raw()['set-cookie']).toHaveLength(1) + const bucket = getCookieFromResponse(res, 'bucket') + const expectedText = bucket === 'a' ? 'Welcome Page A' : 'Welcome Page B' + const browser = await webdriver(next.url, `${locale}/rewrite-to-ab-test`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/rewrite-to-ab-test` + ) + } finally { + await browser.close() + } + // -1 is returned if bucket was not found in func getCookieFromResponse + expect(bucket).not.toBe(-1) + expect($('.title').text()).toBe(expectedText) + }) + + it(`${label}should clear query parameters`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/clear-query-params`, { + a: '1', + b: '2', + foo: 'bar', + allowed: 'kept', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect(JSON.parse($('#my-query-params').text())).toEqual({ + allowed: 'kept', + }) + }) + + it(`${label}should rewrite to about page`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-me-to-about`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/rewrite-me-to-about`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/rewrite-me-to-about` + ) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('About Page') + }) + + it(`${label}support colons in path`, async () => { + const path = `${locale}/not:param` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('not:param') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon`, async () => { + const path = `${locale}/rewrite-me-with-a-colon` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon`, async () => { + const path = `${locale}/colon:here` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon and retain query parameter`, async () => { + const path = `${locale}/colon:here?qp=arg` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(next.url, path) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon and retain query parameter`, async () => { + const path = `${locale}/rewrite-me-with-a-colon?qp=arg` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(next.url, path) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(path) + } finally { + await browser.close() + } + }) + + if (!(global as any).isNextDeploy) { + it(`${label}should rewrite when not using localhost`, async () => { + const customUrl = new URL(next.url) + customUrl.hostname = 'localtest.me' + + const res = await fetchViaHTTP( + customUrl.toString(), + `${locale}/rewrite-me-without-hard-navigation` + ) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) + } + + it(`${label}should rewrite to Vercel`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-me-to-vercel`) + const html = await res.text() + // const browser = await webdriver(next.url, '/rewrite-me-to-vercel') + // TODO: running this to chech the window.location.pathname hangs for some reason; + expect(html).toContain('Example Domain') + }) + + it(`${label}should rewrite without hard navigation`, async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.middleware') + expect(await element.text()).toEqual('foo') + }) + + it(`${label}should not call middleware with shallow push`, async () => { + const browser = await webdriver(next.url, '') + await browser.elementByCss('#link-to-shallow-push').click() + await browser.waitForCondition( + 'new URL(window.location.href).searchParams.get("path") === "rewrite-me-without-hard-navigation"' + ) + await expect(async () => { + await browser.waitForElementByCss('.refreshed', 500) + }).rejects.toThrow() + }) + + it(`${label}should correctly rewriting to a different dynamic path`, async () => { + const browser = await webdriver(next.url, '/dynamic-replace') + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('Parts page') + const logs = await browser.log() + expect( + logs.every((log) => log.source === 'log' || log.source === 'info') + ).toEqual(true) + }) + + it('should not have unexpected errors', async () => { + expect(next.cliOutput).not.toContain('unhandledRejection') + expect(next.cliOutput).not.toContain('ECONNRESET') + }) + } + + tests() + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/middleware-trailing-slash/app/middleware.js b/test/e2e/middleware-trailing-slash/app/middleware.js new file mode 100644 index 0000000000..4d6d5b3204 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/middleware.js @@ -0,0 +1,106 @@ +import { NextResponse, URLPattern } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (request.headers.get('x-prerender-revalidate')) { + return NextResponse.next({ + headers: { 'x-middleware': 'hi' }, + }) + } + + if (url.pathname === '/about/') { + return NextResponse.rewrite(new URL('/about/a', request.url)) + } + + if (url.pathname === '/ssr-page/') { + url.pathname = '/ssr-page-2' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/') { + url.pathname = '/ssg/first' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/to-ssg/') { + url.pathname = '/ssg/hello' + url.searchParams.set('from', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/sha/') { + url.pathname = '/shallow' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-dynamic/') { + url.pathname = '/blog/from-middleware' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-config-rewrite/') { + url.pathname = '/rewrite-3' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/redirect-to-somewhere/') { + url.pathname = '/somewhere' + return NextResponse.redirect(url, { + headers: { + 'x-redirect-header': 'hi', + }, + }) + } + + const original = new URL(request.url) + return NextResponse.next({ + headers: { + 'req-url-path': `${original.pathname}${original.search}`, + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-query': request.nextUrl.searchParams.get('foo'), + 'req-url-locale': request.nextUrl.locale, + 'req-url-params': + url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}', + }, + }) +} + +const PATTERNS = [ + [ + new URLPattern({ pathname: '/:locale/:id' }), + ({ pathname }) => ({ + pathname: '/:locale/:id', + params: pathname.groups, + }), + ], + [ + new URLPattern({ pathname: '/:id' }), + ({ pathname }) => ({ + pathname: '/:id', + params: pathname.groups, + }), + ], +] + +const params = (url) => { + const input = url.split('?')[0] + let result = {} + + for (const [pattern, handler] of PATTERNS) { + const patternResult = pattern.exec(input) + if (patternResult !== null && 'pathname' in patternResult) { + result = handler(patternResult) + break + } + } + return result +} diff --git a/test/e2e/middleware-trailing-slash/app/next.config.js b/test/e2e/middleware-trailing-slash/app/next.config.js new file mode 100644 index 0000000000..0c482151c3 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/next.config.js @@ -0,0 +1,32 @@ +module.exports = { + trailingSlash: true, + redirects() { + return [ + { + source: '/redirect-1', + destination: '/somewhere/else/', + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/rewrite-1', + destination: '/ssr-page?from=config', + }, + { + source: '/rewrite-2', + destination: '/about/a?from=next-config', + }, + { + source: '/sha', + destination: '/shallow', + }, + { + source: '/rewrite-3', + destination: '/blog/middleware-rewrite?hello=config', + }, + ] + }, +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/[id].js b/test/e2e/middleware-trailing-slash/app/pages/[id].js new file mode 100644 index 0000000000..11f4614a67 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/[id].js @@ -0,0 +1,3 @@ +export default function Index() { + return

Dynamic route

+} diff --git a/test/e2e/middleware-trailing-slash/app/pages/_app.js b/test/e2e/middleware-trailing-slash/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/about/a.js b/test/e2e/middleware-trailing-slash/app/pages/about/a.js new file mode 100644 index 0000000000..dfacd4eaff --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/about/a.js @@ -0,0 +1,7 @@ +export default function AboutA() { + return ( +
+

AboutA

+
+ ) +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/about/b.js b/test/e2e/middleware-trailing-slash/app/pages/about/b.js new file mode 100644 index 0000000000..7a44254c45 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/about/b.js @@ -0,0 +1,7 @@ +export default function AboutB() { + return ( +
+

AboutB

+
+ ) +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/api/headers.js b/test/e2e/middleware-trailing-slash/app/pages/api/headers.js new file mode 100644 index 0000000000..0f65c82e9f --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/api/headers.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.json({ url: req.url, headers: req.headers }) +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/blog/[slug].js b/test/e2e/middleware-trailing-slash/app/pages/blog/[slug].js new file mode 100644 index 0000000000..590ca0dcc1 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/blog/[slug].js @@ -0,0 +1,23 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/error-throw.js b/test/e2e/middleware-trailing-slash/app/pages/error-throw.js new file mode 100644 index 0000000000..b8a8706e5f --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/error-throw.js @@ -0,0 +1,12 @@ +export default function ThrowOnData({ message }) { + return ( +
+

Throw on data request

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/middleware-trailing-slash/app/pages/error.js b/test/e2e/middleware-trailing-slash/app/pages/error.js new file mode 100644 index 0000000000..a5a49734cc --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/error.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Errors() { + return ( +
+ + Throw on data + +
+ ) +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/shallow.js b/test/e2e/middleware-trailing-slash/app/pages/shallow.js new file mode 100644 index 0000000000..4d1c8564ee --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/shallow.js @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Shallow({ message }) { + const { pathname, query } = useRouter() + return ( +
+
    +
  • {message}
  • +
  • + + Shallow link to ?hello=world + +
  • +
  • + + Deep link to ?hello=goodbye + +
  • +
  • +

    + Current path: {pathname} +

    +
  • +
  • +

    + Current query: {JSON.stringify(query)} +

    +
  • +
+
+ ) +} + +let i = 0 + +export const getServerSideProps = () => { + return { + props: { + message: `Random: ${++i}${Math.random()}`, + }, + } +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/ssg/[slug].js b/test/e2e/middleware-trailing-slash/app/pages/ssg/[slug].js new file mode 100644 index 0000000000..cf9fb1db0b --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/ssg/[slug].js @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [asPath, setAsPath] = useState( + router.isReady ? router.asPath : router.href + ) + + useEffect(() => { + if (router.isReady) { + setAsPath(router.asPath) + } + }, [router.asPath, router.isReady]) + + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} + +export function getStaticPaths() { + return { + paths: ['/ssg/first', '/ssg/hello'], + fallback: 'blocking', + } +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/ssr-page-2.js b/test/e2e/middleware-trailing-slash/app/pages/ssr-page-2.js new file mode 100644 index 0000000000..67e1b70868 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/ssr-page-2.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Bye Cruel World', + }, + } +} diff --git a/test/e2e/middleware-trailing-slash/app/pages/ssr-page.js b/test/e2e/middleware-trailing-slash/app/pages/ssr-page.js new file mode 100644 index 0000000000..e2aaaa56b4 --- /dev/null +++ b/test/e2e/middleware-trailing-slash/app/pages/ssr-page.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Hello World', + }, + } +} diff --git a/test/e2e/middleware-trailing-slash/test/index.test.ts b/test/e2e/middleware-trailing-slash/test/index.test.ts new file mode 100644 index 0000000000..58f545940a --- /dev/null +++ b/test/e2e/middleware-trailing-slash/test/index.test.ts @@ -0,0 +1,412 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import webdriver from 'next-webdriver' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, waitFor } from 'next-test-utils' + +describe('Middleware Runtime trailing slash', () => { + let next: NextInstance + + afterAll(async () => { + await next.destroy() + }) + beforeAll(async () => { + next = await createNext({ + files: { + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + pages: new FileRef(join(__dirname, '../app/pages')), + }, + }) + }) + + function runTests() { + if ((global as any).isNextDev) { + it('refreshes the page when middleware changes ', async () => { + const browser = await webdriver(next.url, `/about/`) + await browser.eval('window.didrefresh = "hello"') + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('AboutA') + + const middlewarePath = join(next.testDir, '/middleware.js') + const originalContent = fs.readFileSync(middlewarePath, 'utf-8') + const editedContent = originalContent.replace('/about/a', '/about/b') + + try { + fs.writeFileSync(middlewarePath, editedContent) + await waitFor(1000) + const textb = await browser.elementByCss('h1').text() + expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') + expect(textb).toEqual('AboutB') + } finally { + fs.writeFileSync(middlewarePath, originalContent) + await browser.close() + } + }) + } + + if ((global as any).isNextStart) { + it('should have valid middleware field in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + expect(manifest.middleware).toEqual({ + '/': { + files: ['server/edge-runtime-webpack.js', 'server/middleware.js'], + name: 'middleware', + env: [], + page: '/', + matchers: [{ regexp: '^/.*$' }], + wasm: [], + assets: [], + }, + }) + }) + + it('should have correct files in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + for (const key of Object.keys(manifest.middleware)) { + const middleware = manifest.middleware[key] + expect(middleware.files).toContainEqual( + expect.stringContaining('server/edge-runtime-webpack') + ) + expect(middleware.files).not.toContainEqual( + expect.stringContaining('static/chunks/') + ) + } + }) + + it('should not run middleware for on-demand revalidate', async () => { + const bypassToken = ( + await fs.readJSON(join(next.testDir, '.next/prerender-manifest.json')) + ).preview.previewModeId + + const res = await fetchViaHTTP(next.url, '/ssg/first/', undefined, { + headers: { + 'x-prerender-revalidate': bypassToken, + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-middleware')).toBeFalsy() + expect(res.headers.get('x-nextjs-cache')).toBe('REVALIDATED') + }) + } + + it('should have init header for NextResponse.redirect', async () => { + const res = await fetchViaHTTP( + next.url, + '/redirect-to-somewhere/', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere/' + ) + expect(res.headers.get('x-redirect-header')).toBe('hi') + }) + + it('should have correct query values for rewrite to ssg page', async () => { + const browser = await webdriver(next.url, '/to-ssg/') + await browser.eval('window.beforeNav = 1') + + await check(() => browser.elementByCss('body').text(), /\/to-ssg/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'hello', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'hello', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/ssg/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/to-ssg/') + }) + + it('should have correct dynamic route params on client-transition to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/blog/first")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'first', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'first', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/first/') + + await browser.eval('window.next.router.push("/blog/second")') + await check(() => browser.elementByCss('body').text(), /"slug":"second"/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'second', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'second', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/blog/second/' + ) + }) + + it('should have correct dynamic route params for middleware rewrite to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-to-dynamic")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'from-middleware', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'from-middleware', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-dynamic/' + ) + }) + + it('should have correct route params for chained rewrite from middleware to config rewrite', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval( + 'window.next.router.push("/rewrite-to-config-rewrite")' + ) + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-config-rewrite/' + ) + }) + + it('should have correct route params for rewrite from config dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-3")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-3/') + }) + + it('should have correct route params for rewrite from config non-dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-1")') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Hello World/ + ) + + expect(await browser.eval('window.next.router.query')).toEqual({ + from: 'config', + }) + }) + + it('should redirect the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/redirect-1/`, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere/else/' + ) + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/redirect-1')`) + await check(async () => { + const pathname = await browser.eval('location.pathname') + return pathname === '/somewhere/else/' ? 'success' : pathname + }, 'success') + }) + + it('should rewrite the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-1/`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Hello World') + + const browser = await webdriver(next.url, `/`) + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push('/rewrite-1')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('Hello World') ? 'success' : content + }, 'success') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should rewrite correctly for non-SSG/SSP page', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-2/`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('AboutA') + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/rewrite-2')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('AboutA') ? 'success' : content + }, 'success') + }) + + it('should respond with 400 on decode failure', async () => { + const res = await fetchViaHTTP(next.url, `/%2/`) + expect(res.status).toBe(400) + + if ((global as any).isNextStart) { + expect(await res.text()).toContain('Bad Request') + } + }) + + it(`should validate & parse request url from any route`, async () => { + const res = await fetchViaHTTP(next.url, `/static/`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/static/') + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe(undefined) + expect(params).toEqual(undefined) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + }) + + it('should trigger middleware for data requests', async () => { + const browser = await webdriver(next.url, `/ssr-page`) + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('Bye Cruel World') + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + const json = await res.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + }) + + it('should normalize data requests into page requests', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/send-url.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.headers.get('req-url-path')).toEqual('/send-url/') + }) + + it('should keep non data requests in their original shape', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + expect(res.headers.get('req-url-path')).toEqual( + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + }) + + it('should add a rewrite header on data requests for rewrites', async () => { + const res = await fetchViaHTTP(next.url, `/ssr-page/`) + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + const json = await dataRes.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + expect(res.headers.get('x-nextjs-matched-path')).toBeNull() + expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( + `/ssr-page-2` + ) + }) + + it('allows shallow linking with middleware', async () => { + const browser = await webdriver(next.url, '/sha/') + const getMessageContents = () => + browser.elementById('message-contents').text() + const ssrMessage = await getMessageContents() + const requests: string[] = [] + + browser.on('request', (x) => { + requests.push(x.url()) + }) + + browser.elementById('deep-link').click() + browser.waitForElementByCss('[data-query-hello="goodbye"]') + const deepLinkMessage = await getMessageContents() + expect(deepLinkMessage).not.toEqual(ssrMessage) + + // Changing the route with a shallow link should not cause a server request + browser.elementById('shallow-link').click() + browser.waitForElementByCss('[data-query-hello="world"]') + expect(await getMessageContents()).toEqual(deepLinkMessage) + + // Check that no server requests were made to ?hello=world, + // as it's a shallow request. + expect(requests.filter((req) => req.includes('_next/data'))).toEqual([ + `${next.url}/_next/data/${next.buildId}/sha.json?hello=goodbye`, + ]) + }) + } + + runTests() +}) diff --git a/test/e2e/new-link-behavior/app/next.config.js b/test/e2e/new-link-behavior/app/next.config.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/test/e2e/new-link-behavior/app/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/e2e/new-link-behavior/app/pages/about.js b/test/e2e/new-link-behavior/app/pages/about.js new file mode 100644 index 0000000000..5c329bc8d2 --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/about.js @@ -0,0 +1,9 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

About Page

+ Home + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/classname-pass-through.js b/test/e2e/new-link-behavior/app/pages/classname-pass-through.js new file mode 100644 index 0000000000..11a6b0f123 --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/classname-pass-through.js @@ -0,0 +1,8 @@ +import Link from 'next/link' +export default function Page() { + return ( + + Home + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/id-pass-through.js b/test/e2e/new-link-behavior/app/pages/id-pass-through.js new file mode 100644 index 0000000000..1d9ef3fe70 --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/id-pass-through.js @@ -0,0 +1,8 @@ +import Link from 'next/link' +export default function Page() { + return ( + + Home + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/index.js b/test/e2e/new-link-behavior/app/pages/index.js new file mode 100644 index 0000000000..5f987d1e65 --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/index.js @@ -0,0 +1,9 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

Home Page

+ About + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/multiple-children.js b/test/e2e/new-link-behavior/app/pages/multiple-children.js new file mode 100644 index 0000000000..21edca2e92 --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/multiple-children.js @@ -0,0 +1,11 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

Home Page

+ + About Additional Children + + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/onclick-prevent-default.js b/test/e2e/new-link-behavior/app/pages/onclick-prevent-default.js new file mode 100644 index 0000000000..57a9e287ea --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/onclick-prevent-default.js @@ -0,0 +1,17 @@ +import Link from 'next/link' +export default function Page() { + return ( + <> +

Onclick prevent default

+ { + e.preventDefault() + console.log('link to home clicked but prevented') + }} + > + Home + + + ) +} diff --git a/test/e2e/new-link-behavior/app/pages/onclick.js b/test/e2e/new-link-behavior/app/pages/onclick.js new file mode 100644 index 0000000000..6c0fa550ec --- /dev/null +++ b/test/e2e/new-link-behavior/app/pages/onclick.js @@ -0,0 +1,13 @@ +import Link from 'next/link' +export default function Page() { + return ( + { + console.log('link to home clicked') + }} + > + Home + + ) +} diff --git a/test/e2e/new-link-behavior/child-a-tag-error.test.ts b/test/e2e/new-link-behavior/child-a-tag-error.test.ts new file mode 100644 index 0000000000..bce50f68b5 --- /dev/null +++ b/test/e2e/new-link-behavior/child-a-tag-error.test.ts @@ -0,0 +1,42 @@ +import { createNext, FileRef } from 'e2e-utils' +import { getRedboxSource, hasRedbox } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' +import path from 'path' + +const appDir = path.join(__dirname, 'child-a-tag-error') + +describe('New Link Behavior with child', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + 'next.config.js': new FileRef(path.join(appDir, 'next.config.js')), + }, + dependencies: { + next: 'latest', + react: 'latest', + 'react-dom': 'latest', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should throw error with child', async () => { + const browser = await webdriver(next.url, `/`) + const link = await browser.elementsByCss('a[href="/about"]') + const msg = + 'Error: Invalid with child. Please remove or use ' + + if ((global as any).isDev) { + expect(next.cliOutput).toContain(msg) + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxSource(browser)).toContain(msg) + expect(link).not.toBeDefined() + } else { + expect(link).toBeDefined() + } + }) +}) diff --git a/test/e2e/new-link-behavior/child-a-tag-error/next.config.js b/test/e2e/new-link-behavior/child-a-tag-error/next.config.js new file mode 100644 index 0000000000..0d6071006a --- /dev/null +++ b/test/e2e/new-link-behavior/child-a-tag-error/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + reactStrictMode: true, +} diff --git a/test/e2e/new-link-behavior/child-a-tag-error/pages/about.js b/test/e2e/new-link-behavior/child-a-tag-error/pages/about.js new file mode 100644 index 0000000000..e3c4a9ffa5 --- /dev/null +++ b/test/e2e/new-link-behavior/child-a-tag-error/pages/about.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

About Page

+ +
Home + + + ) +} diff --git a/test/e2e/new-link-behavior/child-a-tag-error/pages/index.js b/test/e2e/new-link-behavior/child-a-tag-error/pages/index.js new file mode 100644 index 0000000000..a12ba52a7b --- /dev/null +++ b/test/e2e/new-link-behavior/child-a-tag-error/pages/index.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

Home Page

+ + About + + + ) +} diff --git a/test/e2e/new-link-behavior/index.test.ts b/test/e2e/new-link-behavior/index.test.ts new file mode 100644 index 0000000000..4330dc2c67 --- /dev/null +++ b/test/e2e/new-link-behavior/index.test.ts @@ -0,0 +1,93 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' +import path from 'path' + +async function matchLogs(browser, includes: string) { + let found = false + + const browserLogs = await browser.log('browser') + + browserLogs.forEach((log) => { + if (log.message.includes(includes)) { + found = true + } + }) + return found +} + +const appDir = path.join(__dirname, 'app') + +describe('New Link Behavior', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + 'next.config.js': new FileRef(path.join(appDir, 'next.config.js')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should render link with ', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + const $a = $('a') + expect($a.text()).toBe('About') + expect($a.attr('href')).toBe('/about') + }) + + it('should navigate to /about', async () => { + const browser = await webdriver(next.url, `/`) + await browser.elementByCss('a').click().waitForElementByCss('#about-page') + const text = await browser.elementByCss('h1').text() + expect(text).toBe('About Page') + }) + + it('should handle onclick', async () => { + const browser = await webdriver(next.url, `/onclick`) + await browser.elementByCss('a').click().waitForElementByCss('h1') + const text = await browser.elementByCss('h1').text() + expect(text).toBe('Home Page') + + expect(await matchLogs(browser, 'link to home clicked')).toBe(true) + }) + + it('should handle preventdefault', async () => { + const browser = await webdriver(next.url, `/onclick-prevent-default`) + await browser.elementByCss('a').click() + const text = await browser.elementByCss('h1').text() + expect(text).toBe('Onclick prevent default') + + expect(await matchLogs(browser, 'link to home clicked but prevented')).toBe( + true + ) + }) + + it('should render link with id', async () => { + const html = await renderViaHTTP(next.url, '/id-pass-through') + const $ = cheerio.load(html) + const $a = $('a') + expect($a.attr('id')).toBe('home-link') + }) + + it('should render link with classname', async () => { + const html = await renderViaHTTP(next.url, '/classname-pass-through') + const $ = cheerio.load(html) + const $a = $('a') + expect($a.attr('class')).toBe('home-link') + }) + + it('should render link with multiple children', async () => { + const html = await renderViaHTTP(next.url, '/multiple-children') + const $ = cheerio.load(html) + const $a = $('a') + expect($a.text()).toBe('About Additional Children') + expect($a.find('strong').text()).toBe('Additional Children') + }) +}) diff --git a/test/e2e/new-link-behavior/material-ui.test.ts b/test/e2e/new-link-behavior/material-ui.test.ts new file mode 100644 index 0000000000..0f72ebd47b --- /dev/null +++ b/test/e2e/new-link-behavior/material-ui.test.ts @@ -0,0 +1,46 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' +import path from 'path' + +const appDir = path.join(__dirname, 'material-ui') + +describe('New Link Behavior with material-ui', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(path.join(appDir, 'pages')), + src: new FileRef(path.join(appDir, 'src')), + 'next.config.js': new FileRef(path.join(appDir, 'next.config.js')), + }, + dependencies: { + '@emotion/cache': 'latest', + '@emotion/react': 'latest', + '@emotion/server': 'latest', + '@emotion/styled': 'latest', + '@mui/icons-material': 'latest', + '@mui/material': 'latest', + next: 'latest', + 'prop-types': 'latest', + react: 'latest', + 'react-dom': 'latest', + eslint: 'latest', + 'eslint-config-next': 'latest', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should render MuiLink with ', async () => { + const browser = await webdriver(next.url, `/`) + const element = await browser.elementByCss('a[href="/about"]') + + const color = await element.getComputedCss('color') + expect(color).toBe('rgb(25, 133, 123)') + + const text = await element.text() + expect(text).toBe('Go to the about page') + }) +}) diff --git a/test/e2e/new-link-behavior/material-ui/next.config.js b/test/e2e/new-link-behavior/material-ui/next.config.js new file mode 100644 index 0000000000..0d6071006a --- /dev/null +++ b/test/e2e/new-link-behavior/material-ui/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + reactStrictMode: true, +} diff --git a/test/e2e/new-link-behavior/material-ui/pages/_app.js b/test/e2e/new-link-behavior/material-ui/pages/_app.js new file mode 100644 index 0000000000..e8ed2b4f2c --- /dev/null +++ b/test/e2e/new-link-behavior/material-ui/pages/_app.js @@ -0,0 +1,27 @@ +import * as React from 'react' +import Head from 'next/head' +import { ThemeProvider } from '@mui/material/styles' +import CssBaseline from '@mui/material/CssBaseline' +import { CacheProvider } from '@emotion/react' +import theme from '../src/theme' +import createEmotionCache from '../src/createEmotionCache' + +// Client-side cache, shared for the whole session of the user in the browser. +const clientSideEmotionCache = createEmotionCache() + +export default function MyApp(props) { + const { Component, emotionCache = clientSideEmotionCache, pageProps } = props + + return ( + + + + + + {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} + + + + + ) +} diff --git a/test/e2e/new-link-behavior/material-ui/pages/_document.js b/test/e2e/new-link-behavior/material-ui/pages/_document.js new file mode 100644 index 0000000000..cd639686df --- /dev/null +++ b/test/e2e/new-link-behavior/material-ui/pages/_document.js @@ -0,0 +1,88 @@ +import * as React from 'react' +import Document, { Html, Head, Main, NextScript } from 'next/document' +import createEmotionServer from '@emotion/server/create-instance' +import theme from '../src/theme' +import createEmotionCache from '../src/createEmotionCache' + +export default class MyDocument extends Document { + render() { + return ( + + + {/* PWA primary color */} + + + + {/* Inject MUI styles first to match with the prepend: true configuration. */} + {this.props.emotionStyleTags} + + +
+ + + + ) + } +} + +// `getInitialProps` belongs to `_document` (instead of `_app`), +// it's compatible with static-site generation (SSG). +MyDocument.getInitialProps = async (ctx) => { + // Resolution order + // + // On the server: + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. document.getInitialProps + // 4. app.render + // 5. page.render + // 6. document.render + // + // On the server with error: + // 1. document.getInitialProps + // 2. app.render + // 3. page.render + // 4. document.render + // + // On the client + // 1. app.getInitialProps + // 2. page.getInitialProps + // 3. app.render + // 4. page.render + + const originalRenderPage = ctx.renderPage + + // You can consider sharing the same emotion cache between all the SSR requests to speed up performance. + // However, be aware that it can have global side effects. + const cache = createEmotionCache() + const { extractCriticalToChunks } = createEmotionServer(cache) + + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => + function EnhanceApp(props) { + return + }, + }) + + const initialProps = await Document.getInitialProps(ctx) + // This is important. It prevents emotion to render invalid HTML. + // See https://github.com/mui/material-ui/issues/26561#issuecomment-855286153 + const emotionStyles = extractCriticalToChunks(initialProps.html) + const emotionStyleTags = emotionStyles.styles.map((style) => ( + +

index

+
+ ) +} + +export const config = { runtime: 'experimental-edge' } diff --git a/test/e2e/streaming-ssr/streaming-ssr/pages/multi-byte.js b/test/e2e/streaming-ssr/streaming-ssr/pages/multi-byte.js new file mode 100644 index 0000000000..f92eac96ec --- /dev/null +++ b/test/e2e/streaming-ssr/streaming-ssr/pages/multi-byte.js @@ -0,0 +1,9 @@ +export default function Page() { + return ( +
+

{'マルチバイト'.repeat(28)}

+
+ ) +} + +export const config = { runtime: 'experimental-edge' } diff --git a/test/e2e/streaming-ssr/streaming-ssr/pages/router.js b/test/e2e/streaming-ssr/streaming-ssr/pages/router.js new file mode 100644 index 0000000000..3407d79f07 --- /dev/null +++ b/test/e2e/streaming-ssr/streaming-ssr/pages/router.js @@ -0,0 +1,11 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +export default () => { + useRouter() + return link +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/styled-jsx/app/.npmrc b/test/e2e/styled-jsx/app/.npmrc new file mode 100644 index 0000000000..4c2f52b3be --- /dev/null +++ b/test/e2e/styled-jsx/app/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/test/e2e/styled-jsx/app/node_modules_bak/my-comps/button.js b/test/e2e/styled-jsx/app/node_modules_bak/my-comps/button.js new file mode 100644 index 0000000000..a800c06432 --- /dev/null +++ b/test/e2e/styled-jsx/app/node_modules_bak/my-comps/button.js @@ -0,0 +1,31 @@ +Object.defineProperty(exports, '__esModule', { value: true }) + +function _interopDefault(ex) { + return ex && typeof ex === 'object' && 'default' in ex ? ex['default'] : ex +} + +var _JSXStyle = _interopDefault(require('styled-jsx/style')) +var React = require('react') + +function Button() { + return React.createElement( + 'button', + { + className: 'jsx-451104437', + }, + 'hello', + React.createElement( + _JSXStyle, + { + id: '451104437', + }, + '.jsx-451104437{color:cyan;}\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkJ1dHRvbi50c3giXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBTWtCLEFBRW1CLFVBQ1oiLCJmaWxlIjoiQnV0dG9uLnRzeCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0JztcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gQnV0dG9uKCkge1xuICByZXR1cm4gKFxuICAgIDxidXR0b24+XG4gICAgICBoZWxsb1xuICAgICAgPHN0eWxlIGpzeD57YFxuICAgICAgICBjb2xybzogcmVkO1xuICAgICAgYH08L3N0eWxlPlxuICAgIDwvYnV0dG9uPlxuICApO1xufVxuIl19 */\n/*@ sourceURL=Button.tsx */' + ) + ) +} + +/** + * @class ExampleComponent + */ + +exports.Button = Button diff --git a/test/e2e/styled-jsx/app/pages/amp.js b/test/e2e/styled-jsx/app/pages/amp.js new file mode 100644 index 0000000000..8f71fdbc5c --- /dev/null +++ b/test/e2e/styled-jsx/app/pages/amp.js @@ -0,0 +1,12 @@ +import { Button } from 'my-comps/button' + +export const config = { amp: true } + +export default function page() { + return ( + <> +

Hello world

+ + + ) +} diff --git a/test/e2e/styled-jsx/app/pages/index.js b/test/e2e/styled-jsx/app/pages/index.js new file mode 100644 index 0000000000..378661135f --- /dev/null +++ b/test/e2e/styled-jsx/app/pages/index.js @@ -0,0 +1,11 @@ +import { Button } from 'my-comps/button' + +export default function Page() { + return ( +
+ +

hello world

+ +
+ ) +} diff --git a/test/e2e/styled-jsx/index.test.ts b/test/e2e/styled-jsx/index.test.ts new file mode 100644 index 0000000000..aa22928ece --- /dev/null +++ b/test/e2e/styled-jsx/index.test.ts @@ -0,0 +1,68 @@ +import path from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' +import webdriver from 'next-webdriver' + +const appDir = path.join(__dirname, 'app') + +function runTest() { + describe(`styled-jsx`, () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + node_modules_bak: new FileRef(path.join(appDir, 'node_modules_bak')), + pages: new FileRef(path.join(appDir, 'pages')), + '.npmrc': new FileRef(path.join(appDir, '.npmrc')), + }, + packageJson: { + scripts: { + setup: `cp -r ./node_modules_bak/my-comps ./node_modules;`, + build: `yarn setup && next build`, + dev: `yarn setup && next dev`, + start: 'next start', + }, + }, + dependencies: { + 'styled-jsx': '5.0.0', // styled-jsx on user side + }, + startCommand: 'yarn ' + ((global as any).isNextDev ? 'dev' : 'start'), + buildCommand: `yarn build`, + }) + }) + afterAll(() => next.destroy()) + + it('should contain styled-jsx styles during SSR', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toMatch(/color:.*?red/) + expect(html).toMatch(/color:.*?cyan/) + }) + + it('should render styles during CSR', async () => { + const browser = await webdriver(next.url, '/') + const color = await browser.eval( + `getComputedStyle(document.querySelector('button')).color` + ) + + expect(color).toMatch('0, 255, 255') + }) + + it('should render styles during CSR (AMP)', async () => { + const browser = await webdriver(next.url, '/amp') + const color = await browser.eval( + `getComputedStyle(document.querySelector('button')).color` + ) + + expect(color).toMatch('0, 255, 255') + }) + + it('should render styles during SSR (AMP)', async () => { + const html = await renderViaHTTP(next.url, '/amp') + expect(html).toMatch(/color:.*?cyan/) + }) + }) +} + +runTest() diff --git a/test/e2e/swc-warnings/index.test.ts b/test/e2e/swc-warnings/index.test.ts new file mode 100644 index 0000000000..018e7b8113 --- /dev/null +++ b/test/e2e/swc-warnings/index.test.ts @@ -0,0 +1,68 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('swc warnings by default', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + '.babelrc': ` + { + "presets": ["next/babel"] + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should have warning', async () => { + await renderViaHTTP(next.url, '/') + expect(next.cliOutput).toContain( + 'Disabled SWC as replacement for Babel because of custom Babel configuration' + ) + }) +}) + +describe('can force swc', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + nextConfig: { + experimental: { + forceSwcTransforms: true, + }, + }, + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + '.babelrc': ` + { + "presets": ["next/babel"] + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should not have warning', async () => { + await renderViaHTTP(next.url, '/') + expect(next.cliOutput).not.toContain( + 'Disabled SWC as replacement for Babel because of custom Babel configuration' + ) + }) +}) diff --git a/test/e2e/switchable-runtime/app/edge-rsc/page.server.js.bak b/test/e2e/switchable-runtime/app/edge-rsc/page.server.js.bak new file mode 100644 index 0000000000..629a1d3944 --- /dev/null +++ b/test/e2e/switchable-runtime/app/edge-rsc/page.server.js.bak @@ -0,0 +1,18 @@ +import Runtime from '../../utils/runtime' +import Time from '../../utils/time' + +export default function Page() { + return ( +
+ This is a SSR RSC page. +
+ +
+
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/app/layout.js b/test/e2e/switchable-runtime/app/layout.js new file mode 100644 index 0000000000..11b83aac1a --- /dev/null +++ b/test/e2e/switchable-runtime/app/layout.js @@ -0,0 +1,14 @@ +export const config = { + revalidate: 0, +} + +export default function Root({ children }) { + return ( + + + switchable runtime + + {children} + + ) +} diff --git a/test/e2e/switchable-runtime/app/legacy-extension/page.server.js b/test/e2e/switchable-runtime/app/legacy-extension/page.server.js new file mode 100644 index 0000000000..3fd783a584 --- /dev/null +++ b/test/e2e/switchable-runtime/app/legacy-extension/page.server.js @@ -0,0 +1,3 @@ +export default function Page() { + return

This file won't be treated as entry page file

+} diff --git a/test/e2e/switchable-runtime/app/node-rsc-isr/page.js b/test/e2e/switchable-runtime/app/node-rsc-isr/page.js new file mode 100644 index 0000000000..c15bf6977e --- /dev/null +++ b/test/e2e/switchable-runtime/app/node-rsc-isr/page.js @@ -0,0 +1,22 @@ +import { use } from 'react' +import Runtime from '../../utils/runtime' +import Time from '../../utils/time' + +async function getData() { + return { + type: 'ISR', + } +} + +export default function Page(props) { + const { type } = use(getData()) + return ( +
+ This is a {type} RSC page. +
+ +
+
+ ) +} diff --git a/test/e2e/switchable-runtime/app/node-rsc-ssg/page.js b/test/e2e/switchable-runtime/app/node-rsc-ssg/page.js new file mode 100644 index 0000000000..ef13263a90 --- /dev/null +++ b/test/e2e/switchable-runtime/app/node-rsc-ssg/page.js @@ -0,0 +1,24 @@ +import { use } from 'react' +import Runtime from '../../utils/runtime' +import Time from '../../utils/time' + +async function getData() { + return { + props: { + type: 'SSG', + }, + } +} + +export default function Page(props) { + const { type } = use(getData()) + return ( +
+ This is a {type} RSC page. +
+ +
+
+ ) +} diff --git a/test/e2e/switchable-runtime/app/node-rsc-ssr/page.js b/test/e2e/switchable-runtime/app/node-rsc-ssr/page.js new file mode 100644 index 0000000000..5d0dc512f5 --- /dev/null +++ b/test/e2e/switchable-runtime/app/node-rsc-ssr/page.js @@ -0,0 +1,26 @@ +import { use } from 'react' +import Runtime from '../../utils/runtime' +import Time from '../../utils/time' + +async function getData() { + return { + type: 'SSR', + } +} + +export const config = { + runtime: 'nodejs', +} + +export default function Page(props) { + const { type } = use(getData()) + return ( +
+ This is a {type} RSC page. +
+ +
+
+ ) +} diff --git a/test/e2e/switchable-runtime/app/node-rsc/page.js b/test/e2e/switchable-runtime/app/node-rsc/page.js new file mode 100644 index 0000000000..3f110f6858 --- /dev/null +++ b/test/e2e/switchable-runtime/app/node-rsc/page.js @@ -0,0 +1,20 @@ +import Runtime from '../../utils/runtime' +import Time from '../../utils/time' + +export default function Page() { + return ( +
+ This is a static RSC page. +
+ +
+
+ ) +} + +Page.title = 'node-rsc' + +export const config = { + runtime: 'nodejs', +} diff --git a/test/e2e/switchable-runtime/index.test.ts b/test/e2e/switchable-runtime/index.test.ts new file mode 100644 index 0000000000..07b3a0f58d --- /dev/null +++ b/test/e2e/switchable-runtime/index.test.ts @@ -0,0 +1,728 @@ +/* eslint-env jest */ +import webdriver from 'next-webdriver' +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, renderViaHTTP, waitFor } from 'next-test-utils' + +import { readJson } from 'fs-extra' + +function splitLines(text) { + return text + .split(/\r?\n/g) + .map((str) => str.trim()) + .filter(Boolean) +} + +async function testRoute(appPort, url, { isStatic, isEdge }) { + const html1 = await renderViaHTTP(appPort, url) + const renderedAt1 = +html1.match(/Time: (\d+)/)[1] + expect(html1).toContain(`Runtime: ${isEdge ? 'Edge' : 'Node.js'}`) + + const html2 = await renderViaHTTP(appPort, url) + const renderedAt2 = +html2.match(/Time: (\d+)/)[1] + expect(html2).toContain(`Runtime: ${isEdge ? 'Edge' : 'Node.js'}`) + + if (isStatic) { + // TODO: enable static opt tests + // Should not be re-rendered, some timestamp should be returned. + // expect(renderedAt1).toBe(renderedAt2) + } else { + // Should be re-rendered. + expect(renderedAt1).toBeLessThan(renderedAt2) + } +} + +describe('Switchable runtime', () => { + let next: NextInstance + let context + + if ((global as any).isNextDeploy) { + // TODO-APP: re-enable after Prerenders are handled on deploy + it('should skip for deploy temporarily', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: { + app: new FileRef(join(__dirname, './app')), + pages: new FileRef(join(__dirname, './pages')), + utils: new FileRef(join(__dirname, './utils')), + 'next.config.js': new FileRef(join(__dirname, './next.config.js')), + }, + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + }) + context = { + appPort: next.url, + appDir: next.testDir, + stdout: '', + stderr: '', + } + }) + afterAll(() => next.destroy()) + + if ((global as any).isNextDev) { + describe('Switchable runtime (dev)', () => { + it('should not include edge api routes and edge ssr routes into dev middleware manifest', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json` + ) + const devMiddlewareManifest = await res.json() + expect(devMiddlewareManifest).toEqual([]) + }) + + it('should sort edge SSR routes correctly', async () => { + const res = await fetchViaHTTP(next.url, `/edge/foo`) + const html = await res.text() + + // /edge/foo should be caught before /edge/[id] + expect(html).toContain(`to /edge/[id]`) + }) + + it('should be able to navigate between edge SSR routes without any errors', async () => { + const res = await fetchViaHTTP(next.url, `/edge/foo`) + const html = await res.text() + + // /edge/foo should be caught before /edge/[id] + expect(html).toContain(`to /edge/[id]`) + + const browser = await webdriver(context.appPort, '/edge/foo') + + await browser.waitForElementByCss('a').click() + + // on /edge/[id] + await check( + () => browser.eval('document.documentElement.innerHTML'), + /to \/edge\/foo/ + ) + + await browser.waitForElementByCss('a').click() + + // on /edge/foo + await check( + () => browser.eval('document.documentElement.innerHTML'), + /to \/edge\/\[id\]/ + ) + + expect(context.stdout).not.toContain('self is not defined') + expect(context.stderr).not.toContain('self is not defined') + }) + + it.skip('should support client side navigation to ssr rsc pages', async () => { + let flightRequest = null + + const browser = await webdriver(context.appPort, '/node', { + beforePageLoad(page) { + page.on('request', (request) => { + return request.allHeaders().then((headers) => { + if (headers['RSC'.toLowerCase()] === '1') { + flightRequest = request.url() + } + }) + }) + }, + }) + + await browser + .waitForElementByCss('#link-node-rsc-ssr') + .click() + .waitForElementByCss('.node-rsc-ssr') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /This is a SSR RSC page/ + ) + expect(flightRequest).toContain('/node-rsc-ssr') + }) + + it.skip('should support client side navigation to ssg rsc pages', async () => { + const browser = await webdriver(context.appPort, '/node') + + await browser + .waitForElementByCss('#link-node-rsc-ssg') + .click() + .waitForElementByCss('.node-rsc-ssg') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /This is a SSG RSC page/ + ) + }) + + it.skip('should support client side navigation to static rsc pages', async () => { + const browser = await webdriver(context.appPort, '/node') + + await browser + .waitForElementByCss('#link-node-rsc') + .click() + .waitForElementByCss('.node-rsc') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /This is a static RSC page/ + ) + }) + + it('should not consume server.js file extension', async () => { + const { status } = await fetchViaHTTP( + context.appPort, + '/legacy-extension' + ) + expect(status).toBe(404) + }) + + it('should build /api/hello and /api/edge as an api route with edge runtime', async () => { + let response = await fetchViaHTTP(context.appPort, '/api/hello') + let text = await response.text() + expect(text).toMatch(/Hello from .+\/api\/hello/) + + response = await fetchViaHTTP(context.appPort, '/api/edge') + text = await response.text() + expect(text).toMatch(/Returned by Edge API Route .+\/api\/edge/) + + if (!(global as any).isNextDeploy) { + const manifest = await readJson( + join(context.appDir, '.next/server/middleware-manifest.json') + ) + expect(manifest).toMatchObject({ + functions: { + '/api/hello': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/hello.js', + ], + name: 'pages/api/hello', + page: '/api/hello', + matchers: [{ regexp: '^/api/hello$' }], + wasm: [], + }, + '/api/edge': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/edge.js', + ], + name: 'pages/api/edge', + page: '/api/edge', + matchers: [{ regexp: '^/api/edge$' }], + wasm: [], + }, + }, + }) + } + }) + + it('should be possible to switch between runtimes in API routes', async () => { + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev'), + 'server response' + ) + + // Edge + await next.patchFile( + 'pages/api/switch-in-dev.js', + ` + export const config = { + runtime: 'experimental-edge', + } + + export default () => new Response('edge response') + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev'), + 'edge response' + ) + + // Server + await next.patchFile( + 'pages/api/switch-in-dev.js', + ` + export default function (req, res) { + res.send('server response again') + } + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev'), + 'server response again' + ) + + // Edge + await next.patchFile( + 'pages/api/switch-in-dev.js', + ` + export const config = { + runtime: 'experimental-edge', + } + + export default () => new Response('edge response again') + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev'), + 'edge response again' + ) + }) + + it('should be possible to switch between runtimes in pages', async () => { + await check( + () => renderViaHTTP(next.url, '/switch-in-dev'), + /Hello from edge page/ + ) + + // Server + await next.patchFile( + 'pages/switch-in-dev.js', + ` + export default function Page() { + return

Hello from server page

+ } + ` + ) + await check( + () => renderViaHTTP(next.url, '/switch-in-dev'), + /Hello from server page/ + ) + + // Edge + await next.patchFile( + 'pages/switch-in-dev.js', + ` + export default function Page() { + return

Hello from edge page again

+ } + + export const config = { + runtime: 'experimental-edge', + } + ` + ) + await check( + () => renderViaHTTP(next.url, '/switch-in-dev'), + /Hello from edge page again/ + ) + + // Server + await next.patchFile( + 'pages/switch-in-dev.js', + ` + export default function Page() { + return

Hello from server page again

+ } + ` + ) + await check( + () => renderViaHTTP(next.url, '/switch-in-dev'), + /Hello from server page again/ + ) + }) + + // Doesn't work, see https://github.com/vercel/next.js/pull/39327 + it.skip('should be possible to switch between runtimes with same content', async () => { + const fileContent = await next.readFile( + 'pages/api/switch-in-dev-same-content.js' + ) + console.log({ fileContent }) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev-same-content'), + 'server response' + ) + + // Edge + await next.patchFile( + 'pages/api/switch-in-dev-same-content.js', + ` + export const config = { + runtime: 'experimental-edge', + } + + export default () => new Response('edge response') + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev-same-content'), + 'edge response' + ) + + // Server - same content as first compilation of the server runtime version + await next.patchFile( + 'pages/api/switch-in-dev-same-content.js', + fileContent + ) + await check( + () => renderViaHTTP(next.url, '/api/switch-in-dev-same-content'), + 'server response' + ) + }) + + it('should recover from syntax error when using edge runtime', async () => { + await check( + () => renderViaHTTP(next.url, '/api/syntax-error-in-dev'), + 'edge response' + ) + + // Syntax error + await next.patchFile( + 'pages/api/syntax-error-in-dev.js', + ` + export const config = { + runtime: 'experimental-edge', + } + + export default => new Response('edge response') + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/syntax-error-in-dev'), + /Unexpected token/ + ) + + // Fix syntax error + await next.patchFile( + 'pages/api/syntax-error-in-dev.js', + ` + export default () => new Response('edge response again') + + export const config = { + runtime: 'experimental-edge', + } + + ` + ) + await check( + () => renderViaHTTP(next.url, '/api/syntax-error-in-dev'), + 'edge response again' + ) + }) + + it('should not crash the dev server when invalid runtime is configured', async () => { + await check( + () => renderViaHTTP(next.url, '/invalid-runtime'), + /Hello from page without errors/ + ) + + // Invalid runtime type + await next.patchFile( + 'pages/invalid-runtime.js', + ` + export default function Page() { + return

Hello from page with invalid type

+ } + + export const config = { + runtime: 10, + } + ` + ) + await check( + () => renderViaHTTP(next.url, '/invalid-runtime'), + /Hello from page with invalid type/ + ) + expect(next.cliOutput).toInclude( + 'error - The `runtime` config must be a string. Please leave it empty or choose one of:' + ) + + // Invalid runtime + await next.patchFile( + 'pages/invalid-runtime.js', + ` + export default function Page() { + return

Hello from page with invalid runtime

+ } + + export const config = { + runtime: "asd" + } + ` + ) + await check( + () => renderViaHTTP(next.url, '/invalid-runtime'), + /Hello from page with invalid runtime/ + ) + expect(next.cliOutput).toInclude( + 'error - Provided runtime "asd" is not supported. Please leave it empty or choose one of:' + ) + + // Fix the runtime + await next.patchFile( + 'pages/invalid-runtime.js', + ` + export default function Page() { + return

Hello from page without errors

+ } + + export const config = { + runtime: 'experimental-edge', + } + + ` + ) + await check( + () => renderViaHTTP(next.url, '/invalid-runtime'), + /Hello from page without errors/ + ) + }) + }) + } else { + describe('Switchable runtime (prod)', () => { + it('should build /static as a static page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/static', { + isStatic: true, + isEdge: false, + }) + }) + + it.skip('should build /node as a static page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node', { + isStatic: true, + isEdge: false, + }) + }) + + it('should build /node-ssr as a dynamic page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node-ssr', { + isStatic: false, + isEdge: false, + }) + }) + + it.skip('should build /node-ssg as a static page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node-ssg', { + isStatic: true, + isEdge: false, + }) + }) + + it.skip('should build /node-rsc as a static page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node-rsc', { + isStatic: true, + isEdge: false, + }) + }) + + // FIXME: rsc hydration + it.skip('should build /node-rsc-ssr as a dynamic page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node-rsc-ssr', { + isStatic: false, + isEdge: false, + }) + }) + + // FIXME: rsc hydration + it.skip('should build /node-rsc-ssg as a static page with the nodejs runtime', async () => { + await testRoute(context.appPort, '/node-rsc-ssg', { + isStatic: true, + isEdge: false, + }) + }) + + // FIXME: rsc hydration + it.skip('should build /node-rsc-isr as an isr page with the nodejs runtime', async () => { + const html1 = await renderViaHTTP(context.appPort, '/node-rsc-isr') + const renderedAt1 = +html1.match(/Time: (\d+)/)[1] + expect(html1).toContain('Runtime: Node.js') + + const html2 = await renderViaHTTP(context.appPort, '/node-rsc-isr') + const renderedAt2 = +html2.match(/Time: (\d+)/)[1] + expect(html2).toContain('Runtime: Node.js') + + expect(renderedAt1).toBe(renderedAt2) + + // Trigger a revalidation after 3s. + await waitFor(4000) + await renderViaHTTP(context.appPort, '/node-rsc-isr') + + await check(async () => { + const html3 = await renderViaHTTP(context.appPort, '/node-rsc-isr') + const renderedAt3 = +html3.match(/Time: (\d+)/)[1] + return renderedAt2 < renderedAt3 + ? 'success' + : `${renderedAt2} should be less than ${renderedAt3}` + }, 'success') + }) + + it('should build /edge as a dynamic page with the edge runtime', async () => { + await testRoute(context.appPort, '/edge', { + isStatic: false, + isEdge: true, + }) + await testRoute(context.appPort, '/rewrite/edge', { + isStatic: false, + isEdge: true, + }) + }) + + // TODO: edge rsc in app dir + it.skip('should build /edge-rsc as a dynamic page with the edge runtime', async () => { + await testRoute(context.appPort, '/edge-rsc', { + isStatic: false, + isEdge: true, + }) + }) + + it('should build /api/hello and /api/edge as an api route with edge runtime', async () => { + let response = await fetchViaHTTP(context.appPort, '/api/hello') + let text = await response.text() + expect(text).toMatch(/Hello from .+\/api\/hello/) + + response = await fetchViaHTTP(context.appPort, '/api/edge') + text = await response.text() + expect(text).toMatch(/Returned by Edge API Route .+\/api\/edge/) + + // Rewrite should also work + response = await fetchViaHTTP(context.appPort, 'rewrite/api/edge') + text = await response.text() + expect(text).toMatch(/Returned by Edge API Route .+\/api\/edge/) + + if (!(global as any).isNextDeploy) { + const manifest = await readJson( + join(context.appDir, '.next/server/middleware-manifest.json') + ) + expect(manifest).toMatchObject({ + functions: { + '/api/hello': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/hello.js', + ], + name: 'pages/api/hello', + page: '/api/hello', + matchers: [{ regexp: '^/api/hello$' }], + wasm: [], + }, + '/api/edge': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/edge.js', + ], + name: 'pages/api/edge', + page: '/api/edge', + matchers: [{ regexp: '^/api/edge$' }], + wasm: [], + }, + }, + }) + } + }) + + it.skip('should display correct tree view with page types in terminal', async () => { + const stdoutLines = splitLines(context.stdout).filter((line) => + /^[┌├└/]/.test(line) + ) + const expectedOutputLines = splitLines(` + ┌ /_app + ├ ○ /404 + ├ ℇ /api/hello + ├ λ /api/node + ├ ℇ /edge + ├ ℇ /edge-rsc + ├ ○ /node + ├ ● /node-rsc + ├ ● /node-rsc-isr + ├ ● /node-rsc-ssg + ├ λ /node-rsc-ssr + ├ ● /node-ssg + ├ λ /node-ssr + └ ○ /static + `) + + const mappedOutputLines = expectedOutputLines.map((_line, index) => { + /** @type {string} */ + const str = stdoutLines[index] + const beginningOfPath = str.indexOf('/') + const endOfPath = str.indexOf(' ', beginningOfPath) + return str.slice(0, endOfPath) + }) + + expect(mappedOutputLines).toEqual(expectedOutputLines) + }) + + // TODO: static opt + it.skip('should prefetch data for static pages', async () => { + const dataRequests = [] + + const browser = await webdriver(context.appPort, '/node', { + beforePageLoad(page) { + page.on('request', (request) => { + const url = request.url() + if (/\.json$/.test(url)) { + dataRequests.push(url.split('/').pop()) + } + }) + }, + }) + + await browser.eval('window.beforeNav = 1') + + for (const data of [ + 'node-rsc.json', + 'node-rsc-ssg.json', + 'node-rsc-isr.json', + 'node-ssg.json', + ]) { + expect(dataRequests).toContain(data) + } + }) + + it.skip('should support client side navigation to ssr rsc pages', async () => { + let flightRequest = null + + const browser = await webdriver(context.appPort, '/node', { + beforePageLoad(page) { + page.on('request', (request) => { + request.allHeaders().then((headers) => { + if (headers['RSC'.toLowerCase()] === '1') { + flightRequest = request.url() + } + }) + }) + }, + }) + + await browser.waitForElementByCss('#link-node-rsc-ssr').click() + + expect(await browser.elementByCss('body').text()).toContain( + 'This is a SSR RSC page.' + ) + expect(flightRequest).toContain('/node-rsc-ssr') + }) + + it.skip('should support client side navigation to ssg rsc pages', async () => { + const browser = await webdriver(context.appPort, '/node') + + await browser.waitForElementByCss('#link-node-rsc-ssg').click() + expect(await browser.elementByCss('body').text()).toContain( + 'This is a SSG RSC page.' + ) + }) + + it.skip('should support client side navigation to static rsc pages', async () => { + const browser = await webdriver(context.appPort, '/node') + + await browser.waitForElementByCss('#link-node-rsc').click() + expect(await browser.elementByCss('body').text()).toContain( + 'This is a static RSC page.' + ) + }) + + it('should support etag header in the web server', async () => { + const res = await fetchViaHTTP(context.appPort, '/edge', '', { + headers: { + // Make sure the result is static so an etag can be generated. + 'User-Agent': 'Googlebot', + }, + }) + expect(res.headers.get('ETag')).toBeDefined() + }) + }) + } +}) diff --git a/test/e2e/switchable-runtime/next.config.js b/test/e2e/switchable-runtime/next.config.js new file mode 100644 index 0000000000..b286c63398 --- /dev/null +++ b/test/e2e/switchable-runtime/next.config.js @@ -0,0 +1,21 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + reactStrictMode: true, + experimental: { + appDir: true, + }, + async rewrites() { + return { + afterFiles: [ + { + source: '/rewrite/edge', + destination: '/edge', + }, + { + source: '/rewrite/api/edge', + destination: '/api/edge', + }, + ], + } + }, +} diff --git a/test/e2e/switchable-runtime/pages/api/edge.js b/test/e2e/switchable-runtime/pages/api/edge.js new file mode 100644 index 0000000000..d375a24aaa --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/edge.js @@ -0,0 +1,7 @@ +export default (req) => { + return new Response(`Returned by Edge API Route ${req.url}`) +} + +export const config = { + runtime: `experimental-edge`, +} diff --git a/test/e2e/switchable-runtime/pages/api/hello.js b/test/e2e/switchable-runtime/pages/api/hello.js new file mode 100644 index 0000000000..7f27052a3f --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/hello.js @@ -0,0 +1,7 @@ +export default (req) => { + return new Response(`Hello from ${req.url}`) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/pages/api/node.js b/test/e2e/switchable-runtime/pages/api/node.js new file mode 100644 index 0000000000..5587ef8457 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/node.js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.send('Hello, world') +} diff --git a/test/e2e/switchable-runtime/pages/api/switch-in-dev-same-content.js b/test/e2e/switchable-runtime/pages/api/switch-in-dev-same-content.js new file mode 100644 index 0000000000..a587c8cb1a --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/switch-in-dev-same-content.js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.send('server response') +} diff --git a/test/e2e/switchable-runtime/pages/api/switch-in-dev.js b/test/e2e/switchable-runtime/pages/api/switch-in-dev.js new file mode 100644 index 0000000000..a587c8cb1a --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/switch-in-dev.js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.send('server response') +} diff --git a/test/e2e/switchable-runtime/pages/api/syntax-error-in-dev.js b/test/e2e/switchable-runtime/pages/api/syntax-error-in-dev.js new file mode 100644 index 0000000000..3b7243a120 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/api/syntax-error-in-dev.js @@ -0,0 +1,5 @@ +export default () => new Response('edge response') + +export const config = { + runtime: `experimental-edge`, +} diff --git a/test/e2e/switchable-runtime/pages/edge.js b/test/e2e/switchable-runtime/pages/edge.js new file mode 100644 index 0000000000..2393a75e31 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/edge.js @@ -0,0 +1,18 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +export default function Page() { + return ( +
+ This is a SSR page. +
+ +
+
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/pages/edge/[id].js b/test/e2e/switchable-runtime/pages/edge/[id].js new file mode 100644 index 0000000000..8e91214e16 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/edge/[id].js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ to /edge/foo +
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/pages/edge/foo.js b/test/e2e/switchable-runtime/pages/edge/foo.js new file mode 100644 index 0000000000..b14bc44d88 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/edge/foo.js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ to /edge/[id] +
+ ) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/pages/invalid-runtime.js b/test/e2e/switchable-runtime/pages/invalid-runtime.js new file mode 100644 index 0000000000..1723515712 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/invalid-runtime.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello from page without errors

+} diff --git a/test/e2e/switchable-runtime/pages/node-ssg.js b/test/e2e/switchable-runtime/pages/node-ssg.js new file mode 100644 index 0000000000..ccd6832cf2 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/node-ssg.js @@ -0,0 +1,26 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +export default function Page({ type }) { + return ( +
+ This is a {type} page. +
+ +
+
+ ) +} + +export function getStaticProps() { + return { + props: { + type: 'SSG', + }, + } +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/e2e/switchable-runtime/pages/node-ssr.js b/test/e2e/switchable-runtime/pages/node-ssr.js new file mode 100644 index 0000000000..1f7ee2e0b1 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/node-ssr.js @@ -0,0 +1,26 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +export default function Page({ type }) { + return ( +
+ This is a {type} page. +
+ +
+
+ ) +} + +export function getServerSideProps() { + return { + props: { + type: 'SSR', + }, + } +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/e2e/switchable-runtime/pages/node.js b/test/e2e/switchable-runtime/pages/node.js new file mode 100644 index 0000000000..889e58523b --- /dev/null +++ b/test/e2e/switchable-runtime/pages/node.js @@ -0,0 +1,44 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +import Link from 'next/link' + +export default function Page() { + return ( +
+ This is a static page. +
+ +
+
+ ) +} + +export const config = { + runtime: 'nodejs', +} diff --git a/test/e2e/switchable-runtime/pages/static.js b/test/e2e/switchable-runtime/pages/static.js new file mode 100644 index 0000000000..ea410104fb --- /dev/null +++ b/test/e2e/switchable-runtime/pages/static.js @@ -0,0 +1,14 @@ +import Runtime from '../utils/runtime' +import Time from '../utils/time' + +export default function Page() { + return ( +
+ This is a static page. +
+ +
+
+ ) +} diff --git a/test/e2e/switchable-runtime/pages/switch-in-dev.js b/test/e2e/switchable-runtime/pages/switch-in-dev.js new file mode 100644 index 0000000000..b67d4cabd5 --- /dev/null +++ b/test/e2e/switchable-runtime/pages/switch-in-dev.js @@ -0,0 +1,7 @@ +export default function Page() { + return

Hello from edge page

+} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/switchable-runtime/utils/runtime.js b/test/e2e/switchable-runtime/utils/runtime.js new file mode 100644 index 0000000000..bea2a3fe18 --- /dev/null +++ b/test/e2e/switchable-runtime/utils/runtime.js @@ -0,0 +1,14 @@ +export default function Runtime() { + let runtime + + if (typeof window !== 'undefined') { + // We have to make sure it matches the existing markup when hydrating. + runtime = document.getElementById('__runtime').textContent + } else { + runtime = + 'Runtime: ' + + (process.version ? `Node.js ${process.version}` : 'Edge/Browser') + } + + return {runtime} +} diff --git a/test/e2e/switchable-runtime/utils/time.js b/test/e2e/switchable-runtime/utils/time.js new file mode 100644 index 0000000000..c0894628bb --- /dev/null +++ b/test/e2e/switchable-runtime/utils/time.js @@ -0,0 +1,12 @@ +export default function Time() { + let time + + if (typeof window !== 'undefined') { + // We have to make sure it matches the existing markup when hydrating. + time = document.getElementById('__time').textContent + } else { + time = 'Time: ' + Date.now() + } + + return {time} +} diff --git a/test/e2e/transpile-packages/index.test.ts b/test/e2e/transpile-packages/index.test.ts new file mode 100644 index 0000000000..9b3d209073 --- /dev/null +++ b/test/e2e/transpile-packages/index.test.ts @@ -0,0 +1,85 @@ +import path from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import webdriver from 'next-webdriver' + +describe('transpile packages', () => { + let next: NextInstance + + if ((global as any).isNextDeploy) { + it('should skip for deploy mode for now', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, './npm')), + dependencies: { + react: 'latest', + 'react-dom': 'latest', + sass: 'latest', + }, + packageJson: { + scripts: { + setup: `cp -r ./node_modules_bak/* ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', + }, + }, + installCommand: 'yarn', + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + }) + }) + afterAll(() => next.destroy()) + + const { isNextDeploy } = global as any + const isReact17 = process.env.NEXT_TEST_REACT_VERSION === '^17' + if (isNextDeploy || isReact17) { + it('should skip tests for next-deploy and react 17', () => {}) + return + } + + describe('css', () => { + it('should handle global css imports inside transpiled modules', async () => { + const browser = await webdriver(next.url, '/global-css') + + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('body')).backgroundColor` + ) + ).toBe('rgb(0, 0, 255)') + }) + + it('should handle global scss imports inside transpiled modules', async () => { + const browser = await webdriver(next.url, '/global-scss') + + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('body')).backgroundColor` + ) + ).toBe('rgb(0, 0, 255)') + }) + + it('should handle css modules imports inside transpiled modules', async () => { + const browser = await webdriver(next.url, '/css-modules') + + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).backgroundColor` + ) + ).toBe('rgb(0, 0, 255)') + }) + + it('should handle scss modules imports inside transpiled modules', async () => { + const browser = await webdriver(next.url, '/scss-modules') + + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('h1')).backgroundColor` + ) + ).toBe('rgb(0, 0, 255)') + }) + }) +}) diff --git a/test/e2e/transpile-packages/npm/next.config.js b/test/e2e/transpile-packages/npm/next.config.js new file mode 100644 index 0000000000..c1f282877d --- /dev/null +++ b/test/e2e/transpile-packages/npm/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + transpilePackages: ['css'], + }, +} diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/global-css.js b/test/e2e/transpile-packages/npm/node_modules_bak/css/global-css.js new file mode 100644 index 0000000000..474de5794f --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/global-css.js @@ -0,0 +1 @@ +import './global.css' diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/global-scss.js b/test/e2e/transpile-packages/npm/node_modules_bak/css/global-scss.js new file mode 100644 index 0000000000..e9e2b6b0e8 --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/global-scss.js @@ -0,0 +1 @@ +import './global.scss' diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/global.css b/test/e2e/transpile-packages/npm/node_modules_bak/css/global.css new file mode 100644 index 0000000000..ae844dcf78 --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/global.css @@ -0,0 +1,3 @@ +body { + background: blue; +} diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/global.scss b/test/e2e/transpile-packages/npm/node_modules_bak/css/global.scss new file mode 100644 index 0000000000..2c32509868 --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/global.scss @@ -0,0 +1,5 @@ +$bg-color: blue; + +body { + background: $bg-color; +} diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/module-css.js b/test/e2e/transpile-packages/npm/node_modules_bak/css/module-css.js new file mode 100644 index 0000000000..2009d9280d --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/module-css.js @@ -0,0 +1,2 @@ +import styles from './style.module.css' +export default styles diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/module-scss.js b/test/e2e/transpile-packages/npm/node_modules_bak/css/module-scss.js new file mode 100644 index 0000000000..f3d5b25a8f --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/module-scss.js @@ -0,0 +1,2 @@ +import styles from './style.module.scss' +export default styles diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/package.json b/test/e2e/transpile-packages/npm/node_modules_bak/css/package.json new file mode 100644 index 0000000000..47a528539c --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/package.json @@ -0,0 +1,3 @@ +{ + "name": "css" +} diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.css b/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.css new file mode 100644 index 0000000000..a723abf2d2 --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.css @@ -0,0 +1,3 @@ +.div { + background: blue; +} diff --git a/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.scss b/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.scss new file mode 100644 index 0000000000..8a17ee599e --- /dev/null +++ b/test/e2e/transpile-packages/npm/node_modules_bak/css/style.module.scss @@ -0,0 +1,5 @@ +$bg-color: blue; + +.div { + background: $bg-color; +} diff --git a/test/e2e/transpile-packages/npm/pages/css-modules.js b/test/e2e/transpile-packages/npm/pages/css-modules.js new file mode 100644 index 0000000000..fb4f8ffbfa --- /dev/null +++ b/test/e2e/transpile-packages/npm/pages/css-modules.js @@ -0,0 +1,5 @@ +import styles from 'css/module-css' + +export default function Index() { + return

Hello world!

+} diff --git a/test/e2e/transpile-packages/npm/pages/global-css.js b/test/e2e/transpile-packages/npm/pages/global-css.js new file mode 100644 index 0000000000..9b48d248b9 --- /dev/null +++ b/test/e2e/transpile-packages/npm/pages/global-css.js @@ -0,0 +1,5 @@ +import 'css/global-css' + +export default function Index() { + return
Hello world!
+} diff --git a/test/e2e/transpile-packages/npm/pages/global-scss.js b/test/e2e/transpile-packages/npm/pages/global-scss.js new file mode 100644 index 0000000000..3152d0eec8 --- /dev/null +++ b/test/e2e/transpile-packages/npm/pages/global-scss.js @@ -0,0 +1,5 @@ +import 'css/global-scss' + +export default function Index() { + return
Hello world!
+} diff --git a/test/e2e/transpile-packages/npm/pages/scss-modules.js b/test/e2e/transpile-packages/npm/pages/scss-modules.js new file mode 100644 index 0000000000..a6e98ef36f --- /dev/null +++ b/test/e2e/transpile-packages/npm/pages/scss-modules.js @@ -0,0 +1,5 @@ +import styles from 'css/module-scss' + +export default function Index() { + return

Hello world!

+} diff --git a/test/e2e/type-module-interop/index.test.ts b/test/e2e/type-module-interop/index.test.ts new file mode 100644 index 0000000000..5e1043d7ca --- /dev/null +++ b/test/e2e/type-module-interop/index.test.ts @@ -0,0 +1,113 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { hasRedbox, renderViaHTTP } from 'next-test-utils' +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' + +describe('Type module interop', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + import Link from 'next/link' + import Head from 'next/head' + import Script from 'next/script' + import dynamic from 'next/dynamic' + import { useAmp } from 'next/amp' + + const Dynamic = dynamic(() => import('../components/example')) + + export default function Page() { + const isAmp = useAmp() + return ( + <> + + This page has a title 🤔 + + + + - - -
- - - - ) - } - `, - 'pages/index.js': ` - export default function Home() { - return ( - <> -

Home page

- - ) - } - `, - }, - dependencies: { - react: 'latest', - 'react-dom': 'latest', - }, - }) - }) - afterAll(() => next.destroy()) - - it('Script is injected server-side', async () => { - let browser: BrowserInterface - - try { - browser = await webdriver(next.url, '/') - - const script = await browser.eval( - `document.querySelector('script[data-nscript="beforeInteractive"]')` - ) - expect(script).not.toBeNull() - } finally { - if (browser) await browser.close() - } - }) -}) - -describe('beforeInteractive in document body', () => { - let next: NextInstance - - beforeAll(async () => { - next = await createNext({ - files: { - 'pages/_document.js': ` - import { Html, Head, Main, NextScript } from 'next/document' - import Script from 'next/script' - - export default function Document() { - return ( - - - -
- - ` - ) - - try { - browser = await webdriver(next.url, '/') - - // Partytown modifies type to "text/partytown-x" after it has been executed in the web worker - await check(async () => { - const processedWorkerScripts = await browser.eval( - `document.querySelectorAll('script[type="text/partytown-x"]').length` - ) - return processedWorkerScripts + '' - }, '1') - - const text = await browser.elementById('text').text() - expect(text).toBe('abc') - } finally { - if (browser) await browser.close() - await next.destroy() - } - }) - - it('Inline worker script through dangerouslySetInnerHtml is modified by Partytown to execute on a worker thread', async () => { - let next: NextInstance - let browser: BrowserInterface - - next = await createNextApp( - `