diff --git a/.github/workflows/e2e-next.yml b/.github/workflows/e2e-next.yml
new file mode 100644
index 0000000000..4244beac2a
--- /dev/null
+++ b/.github/workflows/e2e-next.yml
@@ -0,0 +1,24 @@
+name: Next.js e2e test suite
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ name: E2E tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Installing with LTS Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: 'lts/*'
+ - name: NPM Install
+ run: npm install
+ - name: Run Next.js e2e test suite
+ run: npm run test:next
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_BOT_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: 1d5a5c76-d445-4ae5-b694-b0d3f2e2c395
diff --git a/demos/nx-next-monorepo-demo/package-lock.json b/demos/nx-next-monorepo-demo/package-lock.json
index f53cacafb5..b50438304c 100644
--- a/demos/nx-next-monorepo-demo/package-lock.json
+++ b/demos/nx-next-monorepo-demo/package-lock.json
@@ -16364,6 +16364,186 @@
"name": "local-plugin",
"version": "1.0.0-alpha-local-wrapper",
"license": "MIT"
+ },
+ "node_modules/@next/swc-android-arm-eabi": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.0.3.tgz",
+ "integrity": "sha512-uxfUoj65CdFc1gX2q7GtBX3DhKv9Kn343LMqGNvXyuTpYTGMmIiVY7b9yF8oLWRV0gVKqhZBZifUmoPE8SJU6Q==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-android-arm64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-13.0.3.tgz",
+ "integrity": "sha512-t2k+WDfg7Cq2z/EnalKGsd/9E5F4Hdo1xu+UzZXYDpKUI9zgE6Bz8ajQb8m8txv3qOaWdKuDa5j5ziq9Acd1Xw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.0.3.tgz",
+ "integrity": "sha512-jaI2CMuYWvUtRixV3AIjUhnxUDU1FKOR+8hADMhYt3Yz+pCKuj4RZ0n0nY5qUf3qT1AtvnJXEgyatSFJhSp/wQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-freebsd-x64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.0.3.tgz",
+ "integrity": "sha512-nbyT0toBTJrcj5TCB9pVnQpGJ3utGyQj4CWegZs1ulaeUQ5Z7CS/qt8nRyYyOKYHtOdSCJ9Nw5F/RgKNkdpOdw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm-gnueabihf": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.0.3.tgz",
+ "integrity": "sha512-1naLxYvRUQCoFCU1nMkcQueRc0Iux9xBv1L5pzH2ejtIWFg8BrSgyuluJG4nyAhFCx4WG863IEIkAaefOowVdA==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.0.3.tgz",
+ "integrity": "sha512-3Z4A8JkuGWpMVbUhUPQInK/SLY+kijTT78Q/NZCrhLlyvwrVxaQALJNlXzxDLraUgv4oVH0Wz/FIw1W9PUUhxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.0.3.tgz",
+ "integrity": "sha512-MoYe9SM40UaunTjC+01c9OILLH3uSoeri58kDMu3KF/EFEvn1LZ6ODeDj+SLGlAc95wn46hrRJS2BPmDDE+jFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.0.3.tgz",
+ "integrity": "sha512-z22T5WGnRanjLMXdF0NaNjSpBlEzzY43t5Ysp3nW1oI6gOkub6WdQNZeHIY7A2JwkgSWZmtjLtf+Fzzz38LHeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.0.3.tgz",
+ "integrity": "sha512-ZOMT7zjBFmkusAtr47k8xs/oTLsNlTH6xvYb+iux7yly2hZGwhfBLzPGBsbeMZukZ96IphJTagT+C033s6LNVA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.0.3.tgz",
+ "integrity": "sha512-Q4BM16Djl+Oah9UdGrvjFYgoftYB2jNd+rtRGPX5Mmxo09Ry/KiLbOZnoUyoIxKc1xPyfqMXuaVsAFQLYs0KEQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.0.3.tgz",
+ "integrity": "sha512-Sa8yGkNeRUsic8Qjf7MLIAfP0p0+einK/wIqJ8UO1y76j+8rRQu42AMs5H4Ax1fm9GEYq6I8njHtY59TVpTtGQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.0.3.tgz",
+ "integrity": "sha512-IAptmSqA7k4tQzaw2NAkoEjj3+Dz9ceuvlEHwYh770MMDL4V0ku2m+UHrmn5HUCEDHhgwwjg2nyf6728q2jr1w==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
}
},
"dependencies": {
@@ -26539,6 +26719,78 @@
},
"yocto-queue": {
"version": "0.1.0"
+ },
+ "@next/swc-android-arm-eabi": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.0.3.tgz",
+ "integrity": "sha512-uxfUoj65CdFc1gX2q7GtBX3DhKv9Kn343LMqGNvXyuTpYTGMmIiVY7b9yF8oLWRV0gVKqhZBZifUmoPE8SJU6Q==",
+ "optional": true
+ },
+ "@next/swc-android-arm64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-13.0.3.tgz",
+ "integrity": "sha512-t2k+WDfg7Cq2z/EnalKGsd/9E5F4Hdo1xu+UzZXYDpKUI9zgE6Bz8ajQb8m8txv3qOaWdKuDa5j5ziq9Acd1Xw==",
+ "optional": true
+ },
+ "@next/swc-darwin-x64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.0.3.tgz",
+ "integrity": "sha512-jaI2CMuYWvUtRixV3AIjUhnxUDU1FKOR+8hADMhYt3Yz+pCKuj4RZ0n0nY5qUf3qT1AtvnJXEgyatSFJhSp/wQ==",
+ "optional": true
+ },
+ "@next/swc-freebsd-x64": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.0.3.tgz",
+ "integrity": "sha512-nbyT0toBTJrcj5TCB9pVnQpGJ3utGyQj4CWegZs1ulaeUQ5Z7CS/qt8nRyYyOKYHtOdSCJ9Nw5F/RgKNkdpOdw==",
+ "optional": true
+ },
+ "@next/swc-linux-arm-gnueabihf": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.0.3.tgz",
+ "integrity": "sha512-1naLxYvRUQCoFCU1nMkcQueRc0Iux9xBv1L5pzH2ejtIWFg8BrSgyuluJG4nyAhFCx4WG863IEIkAaefOowVdA==",
+ "optional": true
+ },
+ "@next/swc-linux-arm64-gnu": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.0.3.tgz",
+ "integrity": "sha512-3Z4A8JkuGWpMVbUhUPQInK/SLY+kijTT78Q/NZCrhLlyvwrVxaQALJNlXzxDLraUgv4oVH0Wz/FIw1W9PUUhxA==",
+ "optional": true
+ },
+ "@next/swc-linux-arm64-musl": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.0.3.tgz",
+ "integrity": "sha512-MoYe9SM40UaunTjC+01c9OILLH3uSoeri58kDMu3KF/EFEvn1LZ6ODeDj+SLGlAc95wn46hrRJS2BPmDDE+jFQ==",
+ "optional": true
+ },
+ "@next/swc-linux-x64-gnu": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.0.3.tgz",
+ "integrity": "sha512-z22T5WGnRanjLMXdF0NaNjSpBlEzzY43t5Ysp3nW1oI6gOkub6WdQNZeHIY7A2JwkgSWZmtjLtf+Fzzz38LHeQ==",
+ "optional": true
+ },
+ "@next/swc-linux-x64-musl": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.0.3.tgz",
+ "integrity": "sha512-ZOMT7zjBFmkusAtr47k8xs/oTLsNlTH6xvYb+iux7yly2hZGwhfBLzPGBsbeMZukZ96IphJTagT+C033s6LNVA==",
+ "optional": true
+ },
+ "@next/swc-win32-arm64-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.0.3.tgz",
+ "integrity": "sha512-Q4BM16Djl+Oah9UdGrvjFYgoftYB2jNd+rtRGPX5Mmxo09Ry/KiLbOZnoUyoIxKc1xPyfqMXuaVsAFQLYs0KEQ==",
+ "optional": true
+ },
+ "@next/swc-win32-ia32-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.0.3.tgz",
+ "integrity": "sha512-Sa8yGkNeRUsic8Qjf7MLIAfP0p0+einK/wIqJ8UO1y76j+8rRQu42AMs5H4Ax1fm9GEYq6I8njHtY59TVpTtGQ==",
+ "optional": true
+ },
+ "@next/swc-win32-x64-msvc": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.0.3.tgz",
+ "integrity": "sha512-IAptmSqA7k4tQzaw2NAkoEjj3+Dz9ceuvlEHwYh770MMDL4V0ku2m+UHrmn5HUCEDHhgwwjg2nyf6728q2jr1w==",
+ "optional": true
}
}
}
diff --git a/package-lock.json b/package-lock.json
index c998a5fe75..365bcf7f19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,7 @@
"demos/next-with-edge-functions"
],
"dependencies": {
- "next": "^13.0.0"
+ "next": "^13.0.3"
},
"devDependencies": {
"@babel/core": "^7.15.8",
@@ -36,19 +36,23 @@
"@types/jest": "^27.0.2",
"@types/mocha": "^9.0.0",
"@types/node": "^17.0.10",
- "@types/react": "^17.0.38",
+ "@types/react": "^18.0.25",
"babel-jest": "^27.2.5",
"chance": "^1.1.8",
+ "cheerio": "^1.0.0-rc.12",
"cpy": "^8.1.2",
"cypress": "^9.0.0",
+ "escape-string-regexp": "^2.0.0",
"eslint-config-next": "^12.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-unicorn": "^43.0.2",
+ "execa": "^5.1.1",
"husky": "^7.0.4",
"jest": "^27.0.0",
"jest-fetch-mock": "^3.0.3",
"netlify-plugin-cypress": "^2.2.0",
"npm-run-all": "^4.1.5",
+ "playwright-chromium": "^1.26.1",
"prettier": "^2.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -169,6 +173,17 @@
"typescript": "^4.7.4"
}
},
+ "demos/custom-routes/node_modules/@types/react": {
+ "version": "17.0.52",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
+ "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ },
"demos/default": {
"name": "default-demo",
"version": "1.0.0",
@@ -213,6 +228,17 @@
"typescript": "^4.6.3"
}
},
+ "demos/middleware/node_modules/@types/react": {
+ "version": "17.0.52",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
+ "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ },
"demos/next-auth": {
"name": "next-auth-demo",
"version": "0.0.0",
@@ -238,17 +264,6 @@
"node": ">=16.0.0"
}
},
- "demos/next-auth/node_modules/@types/react": {
- "version": "18.0.25",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz",
- "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==",
- "dev": true,
- "dependencies": {
- "@types/prop-types": "*",
- "@types/scheduler": "*",
- "csstype": "^3.0.2"
- }
- },
"demos/next-export": {
"name": "next-export-demo",
"version": "1.0.0",
@@ -5340,6 +5355,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@sindresorhus/transliterate": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.5.0.tgz",
@@ -5356,6 +5383,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@sinonjs/commons": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.5.tgz",
@@ -5678,10 +5717,10 @@
"devOptional": true
},
"node_modules/@types/react": {
- "version": "17.0.52",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
- "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
- "devOptional": true,
+ "version": "18.0.25",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz",
+ "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==",
+ "dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -7616,6 +7655,214 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/cheerio": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
+ "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
+ "dev": true,
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "htmlparser2": "^8.0.1",
+ "parse5": "^7.0.0",
+ "parse5-htmlparser2-tree-adapter": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cheerio-select/node_modules/css-select": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+ "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cheerio-select/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select/node_modules/domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "dev": true,
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select/node_modules/entities": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
+ "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "dev": true,
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/entities": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
+ "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/htmlparser2": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
+ "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "entities": "^4.3.0"
+ }
+ },
+ "node_modules/cheerio/node_modules/parse5": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz",
+ "integrity": "sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg==",
+ "dev": true,
+ "dependencies": {
+ "entities": "^4.4.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/cheerio/node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
+ "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
+ "dev": true,
+ "dependencies": {
+ "domhandler": "^5.0.2",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -7811,6 +8058,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/clean-stack/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cli-boxes": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
@@ -10385,15 +10644,12 @@
"dev": true
},
"node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
- "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
"dev": true,
"engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": ">=8"
}
},
"node_modules/escodegen": {
@@ -12368,6 +12624,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/figures/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -17584,6 +17852,18 @@
"node": "^14.16.0 || >=16.0.0"
}
},
+ "node_modules/netlify-headers-parser/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/netlify-plugin-cypress": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/netlify-plugin-cypress/-/netlify-plugin-cypress-2.2.0.tgz",
@@ -19554,6 +19834,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/playwright-chromium": {
+ "version": "1.27.1",
+ "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.27.1.tgz",
+ "integrity": "sha512-AXAfmNHVnqByo7dKLwLqEC3aKIUlATwDUHCBwVw/qyRCgGUEoufeFUxFXB7pJ4nppwThph7TFe3fHfoETPqSvg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "playwright-core": "1.27.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.27.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
+ "integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
+ "dev": true,
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -21865,15 +22173,6 @@
"node": ">=10"
}
},
- "node_modules/stack-utils/node_modules/escape-string-regexp": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
- "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@@ -27747,6 +28046,14 @@
"requires": {
"@sindresorhus/transliterate": "^1.0.0",
"escape-string-regexp": "^5.0.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true
+ }
}
},
"@sindresorhus/transliterate": {
@@ -27757,6 +28064,14 @@
"requires": {
"escape-string-regexp": "^5.0.0",
"lodash.deburr": "^4.1.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true
+ }
}
},
"@sinonjs/commons": {
@@ -28065,10 +28380,10 @@
"devOptional": true
},
"@types/react": {
- "version": "17.0.52",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
- "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
- "devOptional": true,
+ "version": "18.0.25",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz",
+ "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==",
+ "dev": true,
"requires": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -29568,6 +29883,157 @@
"integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==",
"dev": true
},
+ "cheerio": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
+ "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
+ "dev": true,
+ "requires": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "htmlparser2": "^8.0.1",
+ "parse5": "^7.0.0",
+ "parse5-htmlparser2-tree-adapter": "^7.0.0"
+ },
+ "dependencies": {
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "dev": true,
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ }
+ },
+ "entities": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
+ "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+ "dev": true
+ },
+ "htmlparser2": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
+ "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "entities": "^4.3.0"
+ }
+ },
+ "parse5": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz",
+ "integrity": "sha512-kwpuwzB+px5WUg9pyK0IcK/shltJN5/OVhQagxhCQNtT9Y9QRZqNY2e1cmbu/paRh5LMnz/oVTVLBpjFmMZhSg==",
+ "dev": true,
+ "requires": {
+ "entities": "^4.4.0"
+ }
+ },
+ "parse5-htmlparser2-tree-adapter": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
+ "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
+ "dev": true,
+ "requires": {
+ "domhandler": "^5.0.2",
+ "parse5": "^7.0.0"
+ }
+ }
+ }
+ },
+ "cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "dependencies": {
+ "css-select": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+ "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ }
+ },
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
+ "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "dev": true,
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.1"
+ }
+ },
+ "entities": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
+ "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+ "dev": true
+ }
+ }
+ },
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -29715,6 +30181,14 @@
"dev": true,
"requires": {
"escape-string-regexp": "5.0.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true
+ }
}
},
"cli-boxes": {
@@ -30905,6 +31379,19 @@
"next": "^13.0.3",
"npm-run-all": "^4.1.5",
"typescript": "^4.7.4"
+ },
+ "dependencies": {
+ "@types/react": {
+ "version": "17.0.52",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
+ "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
+ "dev": true,
+ "requires": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ }
}
},
"cypress": {
@@ -31747,9 +32234,9 @@
"dev": true
},
"escape-string-regexp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
- "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
"dev": true
},
"escodegen": {
@@ -33222,6 +33709,14 @@
"requires": {
"escape-string-regexp": "^5.0.0",
"is-unicode-supported": "^1.2.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true
+ }
}
},
"file-entry-cache": {
@@ -36911,6 +37406,19 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^4.6.3"
+ },
+ "dependencies": {
+ "@types/react": {
+ "version": "17.0.52",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz",
+ "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==",
+ "dev": true,
+ "requires": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ }
}
},
"mime-db": {
@@ -37223,6 +37731,14 @@
"map-obj": "^5.0.0",
"path-exists": "^5.0.0",
"toml": "^3.0.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "dev": true
+ }
}
},
"netlify-plugin-cypress": {
@@ -37420,19 +37936,6 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^4.5.5"
- },
- "dependencies": {
- "@types/react": {
- "version": "18.0.25",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz",
- "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==",
- "dev": true,
- "requires": {
- "@types/prop-types": "*",
- "@types/scheduler": "*",
- "csstype": "^3.0.2"
- }
- }
}
},
"next-export-demo": {
@@ -38632,6 +39135,21 @@
"find-up": "^6.1.0"
}
},
+ "playwright-chromium": {
+ "version": "1.27.1",
+ "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.27.1.tgz",
+ "integrity": "sha512-AXAfmNHVnqByo7dKLwLqEC3aKIUlATwDUHCBwVw/qyRCgGUEoufeFUxFXB7pJ4nppwThph7TFe3fHfoETPqSvg==",
+ "dev": true,
+ "requires": {
+ "playwright-core": "1.27.1"
+ }
+ },
+ "playwright-core": {
+ "version": "1.27.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.27.1.tgz",
+ "integrity": "sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==",
+ "dev": true
+ },
"pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -40381,14 +40899,6 @@
"dev": true,
"requires": {
"escape-string-regexp": "^2.0.0"
- },
- "dependencies": {
- "escape-string-regexp": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
- "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
- "dev": true
- }
}
},
"stackframe": {
diff --git a/package.json b/package.json
index eb66b8c6d7..f838a78c67 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
"postinstall": "run-s build install-husky",
"install-husky": "if-env CI=1 || husky install node_modules/@netlify/eslint-config-node/.husky",
"test": "run-s build:demo test:jest",
+ "test:next": "jest -c test/e2e/jest.config.js",
"test:jest": "jest",
+ "playwright:install": "playwright install --with-deps chromium",
"test:jest:update": "jest --updateSnapshot",
"test:update": "run-s build build:demo test:jest:update"
},
@@ -53,19 +55,23 @@
"@types/jest": "^27.0.2",
"@types/mocha": "^9.0.0",
"@types/node": "^17.0.10",
- "@types/react": "^17.0.38",
+ "@types/react": "^18.0.25",
"babel-jest": "^27.2.5",
"chance": "^1.1.8",
+ "cheerio": "^1.0.0-rc.12",
"cpy": "^8.1.2",
"cypress": "^9.0.0",
+ "escape-string-regexp": "^2.0.0",
"eslint-config-next": "^12.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-unicorn": "^43.0.2",
+ "execa": "^5.1.1",
"husky": "^7.0.4",
"jest": "^27.0.0",
"jest-fetch-mock": "^3.0.3",
"netlify-plugin-cypress": "^2.2.0",
"npm-run-all": "^4.1.5",
+ "playwright-chromium": "^1.26.1",
"prettier": "^2.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -76,7 +82,7 @@
"typescript": "^4.3.4"
},
"dependencies": {
- "next": "^13.0.0"
+ "next": "^13.0.3"
},
"engines": {
"node": ">=16.0.0"
@@ -86,8 +92,9 @@
"./jestSetup.js"
],
"testMatch": [
- "**/test/**/*.js",
- "**/test/**/*.ts",
+ "**/test/**/*.spec.js",
+ "**/test/**/*.spec.ts",
+ "!**/test/e2e/**",
"!**/test/fixtures/**",
"!**/test/sample/**"
],
diff --git a/test/e2e/getserversideprops/app/next.config.js b/test/e2e/getserversideprops/app/next.config.js
new file mode 100644
index 0000000000..ac7d2bcaf0
--- /dev/null
+++ b/test/e2e/getserversideprops/app/next.config.js
@@ -0,0 +1,19 @@
+module.exports = {
+ // replace me
+ async rewrites() {
+ return [
+ {
+ source: '/blog-post-1',
+ destination: '/blog/post-1',
+ },
+ {
+ source: '/blog-post-2',
+ destination: '/blog/post-2?hello=world',
+ },
+ {
+ source: '/blog-:param',
+ destination: '/blog/post-3',
+ },
+ ]
+ },
+}
diff --git a/test/e2e/getserversideprops/app/pages/500.js b/test/e2e/getserversideprops/app/pages/500.js
new file mode 100644
index 0000000000..54cd77c731
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/500.js
@@ -0,0 +1,3 @@
+export default function Error() {
+ return
custom pages/500
+}
diff --git a/test/e2e/getserversideprops/app/pages/_app.js b/test/e2e/getserversideprops/app/pages/_app.js
new file mode 100644
index 0000000000..07a84bbe06
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/_app.js
@@ -0,0 +1,29 @@
+import App from 'next/app'
+
+class MyApp extends App {
+ static async getInitialProps(ctx) {
+ const { req, query, pathname, asPath } = ctx.ctx
+ let pageProps = {}
+
+ if (ctx.Component.getInitialProps) {
+ pageProps = await ctx.Component.getInitialProps(ctx.ctx)
+ }
+
+ return {
+ appProps: {
+ url: (req || {}).url,
+ query,
+ pathname,
+ asPath,
+ },
+ pageProps,
+ }
+ }
+
+ render() {
+ const { Component, pageProps, appProps } = this.props
+ return
+ }
+}
+
+export default MyApp
diff --git a/test/e2e/getserversideprops/app/pages/another/index.js b/test/e2e/getserversideprops/app/pages/another/index.js
new file mode 100644
index 0000000000..52b70952ff
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/another/index.js
@@ -0,0 +1,30 @@
+import Link from 'next/link'
+import path from 'path'
+import fs from 'fs'
+
+export async function getServerSideProps() {
+ const text = fs
+ .readFileSync(path.join(process.cwd(), 'world.txt'), 'utf8')
+ .trim()
+
+ return {
+ props: {
+ world: text,
+ time: new Date().getTime(),
+ },
+ }
+}
+
+export default ({ world, time }) => (
+ <>
+ hello {world}
+ time: {time}
+
+ to home
+
+
+
+ to something
+
+ >
+)
diff --git a/test/e2e/getserversideprops/app/pages/blog/[post]/[comment].js b/test/e2e/getserversideprops/app/pages/blog/[post]/[comment].js
new file mode 100644
index 0000000000..318dbc4eb2
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/blog/[post]/[comment].js
@@ -0,0 +1,25 @@
+import React from 'react'
+import Link from 'next/link'
+
+export async function getServerSideProps({ query }) {
+ return {
+ props: {
+ post: query.post,
+ comment: query.comment,
+ time: new Date().getTime(),
+ },
+ }
+}
+
+export default ({ post, comment, time }) => {
+ return (
+ <>
+ Post: {post}
+ Comment: {comment}
+ time: {time}
+
+ to home
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/blog/[post]/index.js b/test/e2e/getserversideprops/app/pages/blog/[post]/index.js
new file mode 100644
index 0000000000..c1d8ab5be4
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/blog/[post]/index.js
@@ -0,0 +1,44 @@
+import React from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export async function getServerSideProps({ params, resolvedUrl }) {
+ if (params.post === 'post-10') {
+ await new Promise((resolve) => {
+ setTimeout(() => resolve(), 1000)
+ })
+ }
+
+ if (params.post === 'post-100') {
+ throw new Error('such broken..')
+ }
+
+ return {
+ props: {
+ params,
+ resolvedUrl,
+ post: params.post,
+ time: (await import('perf_hooks')).performance.now(),
+ },
+ }
+}
+
+export default ({ post, time, params, appProps, resolvedUrl }) => {
+ const router = useRouter()
+
+ return (
+ <>
+ Post: {post}
+ time: {time}
+ {JSON.stringify(params)}
+ {JSON.stringify(router.query)}
+ {JSON.stringify(appProps.query)}
+ {appProps.url}
+ {resolvedUrl}
+ {router.asPath}
+
+ to home
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/blog/index.js b/test/e2e/getserversideprops/app/pages/blog/index.js
new file mode 100644
index 0000000000..821ca040d6
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/blog/index.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import Link from 'next/link'
+
+export async function getServerSideProps() {
+ return {
+ props: {
+ slugs: ['post-1', 'post-2'],
+ time: (await import('perf_hooks')).performance.now(),
+ },
+ }
+}
+
+export default ({ slugs, time }) => {
+ return (
+ <>
+ Posts: {JSON.stringify(slugs)}
+ time: {time}
+
+ to home
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/catchall/[...path].js b/test/e2e/getserversideprops/app/pages/catchall/[...path].js
new file mode 100644
index 0000000000..6da108a268
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/catchall/[...path].js
@@ -0,0 +1,33 @@
+import React from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export async function getServerSideProps({ params }) {
+ return {
+ props: {
+ world: 'world',
+ params: params || {},
+ time: new Date().getTime(),
+ random: Math.random(),
+ },
+ }
+}
+
+export default ({ world, time, params, random }) => {
+ return (
+ <>
+ hello: {world}
+ time: {time}
+ {random}
+ {JSON.stringify(params)}
+ {JSON.stringify(useRouter().query)}
+
+ to home
+
+
+
+ to another
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/custom-cache.js b/test/e2e/getserversideprops/app/pages/custom-cache.js
new file mode 100644
index 0000000000..ddeb3bd7a1
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/custom-cache.js
@@ -0,0 +1,12 @@
+import React from 'react'
+
+export async function getServerSideProps({ res }) {
+ res.setHeader('Cache-Control', 'public, max-age=3600')
+ return {
+ props: { world: 'world' },
+ }
+}
+
+export default ({ world }) => {
+ return hello: {world}
+}
diff --git a/test/e2e/getserversideprops/app/pages/default-revalidate.js b/test/e2e/getserversideprops/app/pages/default-revalidate.js
new file mode 100644
index 0000000000..2c3f808754
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/default-revalidate.js
@@ -0,0 +1,24 @@
+import Link from 'next/link'
+
+export async function getServerSideProps() {
+ return {
+ props: {
+ world: 'world',
+ time: new Date().getTime(),
+ },
+ }
+}
+
+export default ({ world, time }) => (
+ <>
+ hello {world}
+ time: {time}
+
+ to home
+
+
+
+ to something
+
+ >
+)
diff --git a/test/e2e/getserversideprops/app/pages/early-request-end.js b/test/e2e/getserversideprops/app/pages/early-request-end.js
new file mode 100644
index 0000000000..a92fb40b81
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/early-request-end.js
@@ -0,0 +1,12 @@
+export function getServerSideProps({ res }) {
+ res.statusCode = 200
+ res.end('hello from gssp')
+
+ return {
+ props: {},
+ }
+}
+
+export default function Page(props) {
+ return early request end
+}
diff --git a/test/e2e/getserversideprops/app/pages/enoent.js b/test/e2e/getserversideprops/app/pages/enoent.js
new file mode 100644
index 0000000000..b8dde905c1
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/enoent.js
@@ -0,0 +1,7 @@
+export async function getServerSideProps() {
+ const error = new Error('oof')
+ error.code = 'ENOENT'
+ throw error
+}
+
+export default () => 'hi'
diff --git a/test/e2e/getserversideprops/app/pages/index.js b/test/e2e/getserversideprops/app/pages/index.js
new file mode 100644
index 0000000000..4433c9c2ee
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/index.js
@@ -0,0 +1,100 @@
+import Link from 'next/link'
+import ReactDOM from 'react-dom/server'
+import { RouterContext } from 'next/dist/shared/lib/router-context'
+import { useRouter } from 'next/router'
+
+function RouterComp(props) {
+ const router = useRouter()
+
+ if (!router) {
+ throw new Error('router is missing!')
+ }
+
+ return (
+ <>
+ props {JSON.stringify(props)}
+ router: {JSON.stringify(router)}
+ >
+ )
+}
+
+export async function getServerSideProps({ req, query, preview }) {
+ // this ensures the same router context is used by the useRouter hook
+ // no matter where it is imported
+ console.log(
+ ReactDOM.renderToString(
+
+ hello world
+
+
+ )
+ )
+ return {
+ props: {
+ url: req.url,
+ world: 'world',
+ time: new Date().getTime(),
+ },
+ }
+}
+
+const Page = ({ world, time, url }) => {
+ if (typeof window === 'undefined') {
+ if (url.startsWith('/_next/data/')) {
+ throw new Error('invalid render for data request')
+ }
+ }
+
+ return (
+ <>
+ hello {world}
+ time: {time}
+
+ to non-json
+
+
+
+ to another
+
+
+
+ to something
+
+
+
+ to normal
+
+
+
+ to slow
+
+
+
+ to dynamic
+
+
+ to broken
+
+
+
+ to another dynamic
+
+
+ to something?another=thing
+
+ >
+ )
+}
+
+export default Page
diff --git a/test/e2e/getserversideprops/app/pages/invalid-keys.js b/test/e2e/getserversideprops/app/pages/invalid-keys.js
new file mode 100644
index 0000000000..8c8a5ac53c
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/invalid-keys.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export async function getServerSideProps({ params, query }) {
+ return {
+ world: 'world',
+ query: query || {},
+ params: params || {},
+ time: new Date().getTime(),
+ random: Math.random(),
+ }
+}
+
+export default ({ world, time, params, random, query }) => {
+ return (
+ <>
+ hello: {world}
+ time: {time}
+ {random}
+ {JSON.stringify(params)}
+ {JSON.stringify(query)}
+ {JSON.stringify(useRouter().query)}
+
+ to home
+
+
+
+ to another
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/non-json.js b/test/e2e/getserversideprops/app/pages/non-json.js
new file mode 100644
index 0000000000..dc418a32cf
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/non-json.js
@@ -0,0 +1,11 @@
+export async function getServerSideProps() {
+ return {
+ props: { time: new Date() },
+ }
+}
+
+const Page = ({ time }) => {
+ return hello {time.toString()}
+}
+
+export default Page
diff --git a/test/e2e/getserversideprops/app/pages/normal.js b/test/e2e/getserversideprops/app/pages/normal.js
new file mode 100644
index 0000000000..75ad8dfee1
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/normal.js
@@ -0,0 +1 @@
+export default () => a normal page
diff --git a/test/e2e/getserversideprops/app/pages/not-found/[slug].js b/test/e2e/getserversideprops/app/pages/not-found/[slug].js
new file mode 100644
index 0000000000..131342b70d
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/not-found/[slug].js
@@ -0,0 +1,35 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export default function Page(props) {
+ const router = useRouter()
+
+ return (
+ <>
+ gssp page
+ {JSON.stringify(props)}
+ {JSON.stringify(router.query)}
+ {router.pathname}
+ {router.asPath}
+
+ to /
+
+
+ >
+ )
+}
+
+export const getServerSideProps = ({ query }) => {
+ if (query.hiding) {
+ return {
+ notFound: true,
+ }
+ }
+
+ return {
+ notFound: false,
+ props: {
+ hello: 'world',
+ },
+ }
+}
diff --git a/test/e2e/getserversideprops/app/pages/not-found/index.js b/test/e2e/getserversideprops/app/pages/not-found/index.js
new file mode 100644
index 0000000000..131342b70d
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/not-found/index.js
@@ -0,0 +1,35 @@
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export default function Page(props) {
+ const router = useRouter()
+
+ return (
+ <>
+ gssp page
+ {JSON.stringify(props)}
+ {JSON.stringify(router.query)}
+ {router.pathname}
+ {router.asPath}
+
+ to /
+
+
+ >
+ )
+}
+
+export const getServerSideProps = ({ query }) => {
+ if (query.hiding) {
+ return {
+ notFound: true,
+ }
+ }
+
+ return {
+ notFound: false,
+ props: {
+ hello: 'world',
+ },
+ }
+}
diff --git a/test/e2e/getserversideprops/app/pages/promise/index.js b/test/e2e/getserversideprops/app/pages/promise/index.js
new file mode 100644
index 0000000000..c6871c0fbf
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/promise/index.js
@@ -0,0 +1,15 @@
+export async function getServerSideProps() {
+ return {
+ props: (async function () {
+ return {
+ text: 'promise',
+ }
+ })(),
+ }
+}
+
+export default ({ text }) => (
+ <>
+ hello {text}
+ >
+)
diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res-no-streaming.js b/test/e2e/getserversideprops/app/pages/promise/mutate-res-no-streaming.js
new file mode 100644
index 0000000000..fdc1a9b498
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/promise/mutate-res-no-streaming.js
@@ -0,0 +1,20 @@
+let mutatedResNoStreaming
+
+export async function getServerSideProps(context) {
+ mutatedResNoStreaming = context.res
+ return {
+ props: {
+ text: 'res',
+ },
+ }
+}
+
+export default ({ text }) => {
+ mutatedResNoStreaming.setHeader('test-header', 'this is a test header')
+
+ return (
+ <>
+ hello {text}
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res-props.js b/test/e2e/getserversideprops/app/pages/promise/mutate-res-props.js
new file mode 100644
index 0000000000..a2ef620e2e
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/promise/mutate-res-props.js
@@ -0,0 +1,20 @@
+export async function getServerSideProps(context) {
+ return {
+ props: (async function () {
+ // Mimic some async work, like getting data from an API
+ await new Promise((resolve) => setTimeout(resolve))
+ context.res.setHeader('test-header', 'this is a test header')
+ return {
+ text: 'res',
+ }
+ })(),
+ }
+}
+
+export default ({ text }) => {
+ return (
+ <>
+ hello {text}
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res.js b/test/e2e/getserversideprops/app/pages/promise/mutate-res.js
new file mode 100644
index 0000000000..51ff409b72
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/promise/mutate-res.js
@@ -0,0 +1,22 @@
+let mutatedRes
+
+export async function getServerSideProps(context) {
+ mutatedRes = context.res
+ return {
+ props: (async function () {
+ return {
+ text: 'res',
+ }
+ })(),
+ }
+}
+
+export default ({ text }) => {
+ mutatedRes.setHeader('test-header', 'this is a test header')
+
+ return (
+ <>
+ hello {text}
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/refresh.js b/test/e2e/getserversideprops/app/pages/refresh.js
new file mode 100644
index 0000000000..364760289f
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/refresh.js
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react'
+
+const foo = (query) => query
+
+export const FOO = foo('query')
+
+const MyPage = () => {
+ const [isMounted, setMounted] = useState(false)
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+ if (isMounted) {
+ return client loaded
+ }
+ return server
+}
+
+const getServerSideProps = async () => {
+ return { props: {} }
+}
+
+export default MyPage
+export { getServerSideProps }
diff --git a/test/e2e/getserversideprops/app/pages/slow/index.js b/test/e2e/getserversideprops/app/pages/slow/index.js
new file mode 100644
index 0000000000..5520d0d75b
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/slow/index.js
@@ -0,0 +1,26 @@
+import Link from 'next/link'
+
+let serverHitCount = 0
+
+export async function getServerSideProps() {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return {
+ props: {
+ count: ++serverHitCount,
+ },
+ }
+}
+
+export default ({ count }) => (
+ <>
+ a slow page
+ hit: {count}
+
+ to home
+
+
+
+ to something
+
+ >
+)
diff --git a/test/e2e/getserversideprops/app/pages/something.js b/test/e2e/getserversideprops/app/pages/something.js
new file mode 100644
index 0000000000..ed4efdabe7
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/something.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+export async function getServerSideProps({ params, query, resolvedUrl }) {
+ return {
+ props: {
+ resolvedUrl: resolvedUrl,
+ world: 'world',
+ query: query || {},
+ params: params || {},
+ time: new Date().getTime(),
+ random: Math.random(),
+ },
+ }
+}
+
+export default ({
+ world,
+ time,
+ params,
+ random,
+ query,
+ appProps,
+ resolvedUrl,
+}) => {
+ const router = useRouter()
+
+ return (
+ <>
+ hello: {world}
+ time: {time}
+ {random}
+ {JSON.stringify(params)}
+ {JSON.stringify(query)}
+ {JSON.stringify(router.query)}
+ {JSON.stringify(appProps.query)}
+ {appProps.url}
+ {resolvedUrl}
+ {router.asPath}
+
+ to home
+
+
+
+ to another
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/pages/user/[user]/profile.js b/test/e2e/getserversideprops/app/pages/user/[user]/profile.js
new file mode 100644
index 0000000000..99d0ba4ffe
--- /dev/null
+++ b/test/e2e/getserversideprops/app/pages/user/[user]/profile.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import Link from 'next/link'
+
+export async function getServerSideProps({ query }) {
+ return {
+ props: {
+ user: query.user,
+ time: (await import('perf_hooks')).performance.now(),
+ },
+ }
+}
+
+export default ({ user, time }) => {
+ return (
+ <>
+ User: {user}
+ time: {time}
+
+ to home
+
+ >
+ )
+}
diff --git a/test/e2e/getserversideprops/app/world.txt b/test/e2e/getserversideprops/app/world.txt
new file mode 100644
index 0000000000..04fea06420
--- /dev/null
+++ b/test/e2e/getserversideprops/app/world.txt
@@ -0,0 +1 @@
+world
\ No newline at end of file
diff --git a/test/e2e/getserversideprops/test/index.test.ts b/test/e2e/getserversideprops/test/index.test.ts
new file mode 100644
index 0000000000..50df0414ee
--- /dev/null
+++ b/test/e2e/getserversideprops/test/index.test.ts
@@ -0,0 +1,863 @@
+/* eslint-env jest */
+
+import cheerio from 'cheerio'
+import { createNext, FileRef } from 'e2e-utils'
+import escapeRegex from 'escape-string-regexp'
+import {
+ check,
+ fetchViaHTTP,
+ getBrowserBodyText,
+ getRedboxHeader,
+ normalizeRegEx,
+ renderViaHTTP,
+ waitFor,
+} from 'next-test-utils'
+import { join } from 'path'
+import webdriver from 'next-webdriver'
+import { NextInstance } from 'test/lib/next-modes/base'
+
+const appDir = join(__dirname, '../app')
+
+let buildId
+let next: NextInstance
+
+const expectedManifestRoutes = () => [
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/index.json$`
+ ),
+ page: '/',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/another.json$`
+ ),
+ page: '/another',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog.json$`
+ ),
+ page: '/blog',
+ },
+ {
+ namedDataRouteRegex: `^/_next/data/${escapeRegex(
+ buildId
+ )}/blog/(?[^/]+?)\\.json$`,
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog\\/([^\\/]+?)\\.json$`
+ ),
+ page: '/blog/[post]',
+ routeKeys: {
+ post: 'post',
+ },
+ },
+ {
+ namedDataRouteRegex: `^/_next/data/${escapeRegex(
+ buildId
+ )}/blog/(?[^/]+?)/(?[^/]+?)\\.json$`,
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/blog\\/([^\\/]+?)\\/([^\\/]+?)\\.json$`
+ ),
+ page: '/blog/[post]/[comment]',
+ routeKeys: {
+ post: 'post',
+ comment: 'comment',
+ },
+ },
+ {
+ namedDataRouteRegex: `^/_next/data/${escapeRegex(
+ buildId
+ )}/catchall/(?.+?)\\.json$`,
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/catchall\\/(.+?)\\.json$`
+ ),
+ page: '/catchall/[...path]',
+ routeKeys: {
+ path: 'path',
+ },
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/custom-cache.json$`
+ ),
+ page: '/custom-cache',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/default-revalidate.json$`
+ ),
+ page: '/default-revalidate',
+ },
+ {
+ dataRouteRegex: `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/early-request-end.json$`,
+ page: '/early-request-end',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/enoent.json$`
+ ),
+ page: '/enoent',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/invalid-keys.json$`
+ ),
+ page: '/invalid-keys',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/non-json.json$`
+ ),
+ page: '/non-json',
+ },
+ {
+ dataRouteRegex: `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/not-found.json$`,
+ page: '/not-found',
+ },
+ {
+ dataRouteRegex: `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/not\\-found\\/([^\\/]+?)\\.json$`,
+ namedDataRouteRegex: `^/_next/data/${escapeRegex(
+ buildId
+ )}/not\\-found/(?[^/]+?)\\.json$`,
+ page: '/not-found/[slug]',
+ routeKeys: {
+ slug: 'slug',
+ },
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/promise.json$`
+ ),
+ page: '/promise',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/promise\\/mutate-res.json$`
+ ),
+ page: '/promise/mutate-res',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/promise\\/mutate-res-no-streaming.json$`
+ ),
+ page: '/promise/mutate-res-no-streaming',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/promise\\/mutate-res-props.json$`
+ ),
+ page: '/promise/mutate-res-props',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/refresh.json$`
+ ),
+ page: '/refresh',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/slow.json$`
+ ),
+ page: '/slow',
+ },
+ {
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$`
+ ),
+ page: '/something',
+ },
+ {
+ namedDataRouteRegex: `^/_next/data/${escapeRegex(
+ buildId
+ )}/user/(?[^/]+?)/profile\\.json$`,
+ dataRouteRegex: normalizeRegEx(
+ `^\\/_next\\/data\\/${escapeRegex(
+ buildId
+ )}\\/user\\/([^\\/]+?)\\/profile\\.json$`
+ ),
+ page: '/user/[user]/profile',
+ routeKeys: {
+ user: 'user',
+ },
+ },
+]
+
+const navigateTest = () => {
+ it('should navigate between pages successfully', async () => {
+ const toBuild = [
+ '/',
+ '/another',
+ '/something',
+ '/normal',
+ '/blog/post-1',
+ '/blog/post-1/comment-1',
+ ]
+
+ await Promise.all(toBuild.map((pg) => renderViaHTTP(next.url, pg)))
+
+ const browser = await webdriver(next.url, '/')
+ let text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/hello.*?world/)
+
+ // hydration
+ await waitFor(2500)
+
+ // go to /another
+ async function goFromHomeToAnother() {
+ await browser.elementByCss('#another').click()
+ await browser.waitForElementByCss('#home')
+ text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/hello.*?world/)
+ }
+ await goFromHomeToAnother()
+
+ // go to /
+ async function goFromAnotherToHome() {
+ await browser.eval('window.didTransition = 1')
+ await browser.elementByCss('#home').click()
+ await browser.waitForElementByCss('#another')
+ text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/hello.*?world/)
+ expect(await browser.eval('window.didTransition')).toBe(1)
+ }
+ await goFromAnotherToHome()
+
+ await goFromHomeToAnother()
+ const snapTime = await browser.elementByCss('#anotherTime').text()
+
+ // Re-visit page
+ await goFromAnotherToHome()
+ await goFromHomeToAnother()
+
+ const nextTime = await browser.elementByCss('#anotherTime').text()
+ expect(snapTime).not.toMatch(nextTime)
+
+ // Reset to Home for next test
+ await goFromAnotherToHome()
+
+ // go to /something
+ await browser.elementByCss('#something').click()
+ await browser.waitForElementByCss('#home')
+ text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/hello.*?world/)
+ expect(await browser.eval('window.didTransition')).toBe(1)
+
+ // go to /
+ await browser.elementByCss('#home').click()
+ await browser.waitForElementByCss('#post-1')
+
+ // go to /blog/post-1
+ await browser.elementByCss('#post-1').click()
+ await browser.waitForElementByCss('#home')
+ text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/Post:.*?post-1/)
+ expect(await browser.eval('window.didTransition')).toBe(1)
+
+ // go to /
+ await browser.elementByCss('#home').click()
+ await browser.waitForElementByCss('#comment-1')
+
+ // go to /blog/post-1/comment-1
+ await browser.elementByCss('#comment-1').click()
+ await browser.waitForElementByCss('#home')
+ text = await browser.elementByCss('p:nth-child(2)').text()
+ expect(text).toMatch(/Comment:.*?comment-1/)
+ expect(await browser.eval('window.didTransition')).toBe(1)
+
+ await browser.close()
+ })
+}
+
+const runTests = (isDev = false, isDeploy = false) => {
+ navigateTest()
+
+ it('should work with early request ending', async () => {
+ const res = await fetchViaHTTP(next.url, '/early-request-end')
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('hello from gssp')
+ })
+
+ it('should allow POST request for getServerSideProps page', async () => {
+ const res = await fetchViaHTTP(next.url, '/', undefined, {
+ method: 'POST',
+ })
+ expect(res.status).toBe(200)
+ expect(await res.text()).toMatch(/hello.*?world/)
+ })
+
+ it('should render correctly when notFound is false (non-dynamic)', async () => {
+ const res = await fetchViaHTTP(next.url, '/not-found')
+
+ expect(res.status).toBe(200)
+ })
+
+ it('should render 404 correctly when notFound is returned (non-dynamic)', async () => {
+ const res = await fetchViaHTTP(next.url, '/not-found', { hiding: true })
+
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('This page could not be found')
+ })
+
+ it('should render 404 correctly when notFound is returned client-transition (non-dynamic)', async () => {
+ const browser = await webdriver(next.url, '/')
+ await browser.eval(`(function() {
+ window.beforeNav = 1
+ window.next.router.push('/not-found?hiding=true')
+ })()`)
+
+ await browser.waitForElementByCss('h1')
+ expect(await browser.elementByCss('html').text()).toContain(
+ 'This page could not be found'
+ )
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should render correctly when notFound is false (dynamic)', async () => {
+ const res = await fetchViaHTTP(next.url, '/not-found/first')
+
+ expect(res.status).toBe(200)
+ })
+
+ it('should render 404 correctly when notFound is returned (dynamic)', async () => {
+ const res = await fetchViaHTTP(next.url, '/not-found/first', {
+ hiding: true,
+ })
+
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('This page could not be found')
+ })
+
+ it('should render 404 correctly when notFound is returned client-transition (dynamic)', async () => {
+ const browser = await webdriver(next.url, '/')
+ await browser.eval(`(function() {
+ window.beforeNav = 1
+ window.next.router.push('/not-found/first?hiding=true')
+ })()`)
+
+ await browser.waitForElementByCss('h1')
+ expect(await browser.elementByCss('html').text()).toContain(
+ 'This page could not be found'
+ )
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should SSR normal page correctly', async () => {
+ const html = await renderViaHTTP(next.url, '/')
+ expect(html).toMatch(/hello.*?world/)
+ })
+
+ it('should SSR getServerSideProps page correctly', async () => {
+ const html = await renderViaHTTP(next.url, '/blog/post-1')
+ expect(html).toMatch(/Post:.*?post-1/)
+ })
+
+ it('should handle throw ENOENT correctly', async () => {
+ const res = await fetchViaHTTP(next.url, '/enoent')
+ const html = await res.text()
+
+ if (isDev) {
+ expect(html).toContain('oof')
+ } else {
+ expect(res.status).toBe(500)
+ expect(html).toContain('custom pages/500')
+ expect(html).not.toContain('This page could not be found')
+ }
+ })
+
+ it('should have gssp in __NEXT_DATA__', async () => {
+ const html = await renderViaHTTP(next.url, '/')
+ const $ = cheerio.load(html)
+ expect(JSON.parse($('#__NEXT_DATA__').text()).gssp).toBe(true)
+ })
+
+ it('should not have gssp in __NEXT_DATA__ for non-GSSP page', async () => {
+ const html = await renderViaHTTP(next.url, '/normal')
+ const $ = cheerio.load(html)
+ expect('gssp' in JSON.parse($('#__NEXT_DATA__').text())).toBe(false)
+ })
+
+ it('should supply query values SSR', async () => {
+ const html = await renderViaHTTP(next.url, '/blog/post-1?hello=world')
+ const $ = cheerio.load(html)
+ const params = $('#params').text()
+ expect(JSON.parse(params)).toEqual({ post: 'post-1' })
+ const query = $('#query').text()
+ expect(JSON.parse(query)).toEqual({ hello: 'world', post: 'post-1' })
+ })
+
+ it('should supply params values for catchall correctly', async () => {
+ const html = await renderViaHTTP(next.url, '/catchall/first')
+ const $ = cheerio.load(html)
+ const params = $('#params').text()
+ expect(JSON.parse(params)).toEqual({ path: ['first'] })
+ const query = $('#query').text()
+ expect(JSON.parse(query)).toEqual({ path: ['first'] })
+
+ const data = JSON.parse(
+ await renderViaHTTP(
+ next.url,
+ `/_next/data/${buildId}/catchall/first.json`
+ )
+ )
+
+ expect(data.pageProps.params).toEqual({ path: ['first'] })
+ })
+
+ it('should have original req.url for /_next/data request dynamic page', async () => {
+ const curUrl = `/_next/data/${buildId}/blog/post-1.json`
+ const data = await renderViaHTTP(next.url, curUrl)
+ const { appProps, pageProps } = JSON.parse(data)
+
+ expect(pageProps.resolvedUrl).toEqual('/blog/post-1')
+
+ expect(appProps).toEqual({
+ url: curUrl,
+ query: { post: 'post-1' },
+ asPath: '/blog/post-1',
+ pathname: '/blog/[post]',
+ })
+ })
+
+ it('should have original req.url for /_next/data request dynamic page with query', async () => {
+ const curUrl = `/_next/data/${buildId}/blog/post-1.json`
+ const data = await renderViaHTTP(next.url, curUrl, { hello: 'world' })
+ const { appProps, pageProps } = JSON.parse(data)
+
+ expect(pageProps.resolvedUrl).toEqual('/blog/post-1?hello=world')
+
+ expect(appProps).toEqual({
+ url: curUrl + '?hello=world',
+ query: { post: 'post-1', hello: 'world' },
+ asPath: '/blog/post-1?hello=world',
+ pathname: '/blog/[post]',
+ })
+ })
+
+ it('should have original req.url for /_next/data request', async () => {
+ const curUrl = `/_next/data/${buildId}/something.json`
+ const data = await renderViaHTTP(next.url, curUrl)
+ const { appProps, pageProps } = JSON.parse(data)
+
+ expect(pageProps.resolvedUrl).toEqual('/something')
+
+ expect(appProps).toEqual({
+ url: curUrl,
+ query: {},
+ asPath: '/something',
+ pathname: '/something',
+ })
+ })
+
+ it('should have original req.url for /_next/data request with query', async () => {
+ const curUrl = `/_next/data/${buildId}/something.json`
+ const data = await renderViaHTTP(next.url, curUrl, { hello: 'world' })
+ const { appProps, pageProps } = JSON.parse(data)
+
+ expect(pageProps.resolvedUrl).toEqual('/something?hello=world')
+
+ expect(appProps).toEqual({
+ url: curUrl + '?hello=world',
+ query: { hello: 'world' },
+ asPath: '/something?hello=world',
+ pathname: '/something',
+ })
+ })
+
+ it('should have correct req.url and query for direct visit dynamic page', async () => {
+ const html = await renderViaHTTP(next.url, '/blog/post-1')
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/blog/post-1')
+ expect(JSON.parse($('#app-query').text())).toEqual({ post: 'post-1' })
+ expect($('#resolved-url').text()).toBe('/blog/post-1')
+ expect($('#as-path').text()).toBe('/blog/post-1')
+ })
+
+ it('should have correct req.url and query for direct visit dynamic page rewrite direct', async () => {
+ const html = await renderViaHTTP(next.url, '/blog-post-1')
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/blog-post-1')
+ expect(JSON.parse($('#app-query').text())).toEqual({ post: 'post-1' })
+ expect($('#resolved-url').text()).toBe('/blog/post-1')
+ expect($('#as-path').text()).toBe('/blog-post-1')
+ })
+
+ it('should have correct req.url and query for direct visit dynamic page rewrite direct with internal query', async () => {
+ const html = await renderViaHTTP(next.url, '/blog-post-2')
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/blog-post-2')
+ expect(JSON.parse($('#app-query').text())).toEqual({
+ post: 'post-2',
+ hello: 'world',
+ })
+ expect($('#resolved-url').text()).toBe('/blog/post-2')
+ expect($('#as-path').text()).toBe('/blog-post-2')
+ })
+
+ it('should have correct req.url and query for direct visit dynamic page rewrite param', async () => {
+ const html = await renderViaHTTP(next.url, '/blog-post-3')
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/blog-post-3')
+ expect(JSON.parse($('#app-query').text())).toEqual({
+ post: 'post-3',
+ param: 'post-3',
+ })
+ expect($('#resolved-url').text()).toBe('/blog/post-3')
+ expect($('#as-path').text()).toBe('/blog-post-3')
+ })
+
+ it('should have correct req.url and query for direct visit dynamic page with query', async () => {
+ const html = await renderViaHTTP(next.url, '/blog/post-1', {
+ hello: 'world',
+ })
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/blog/post-1?hello=world')
+ expect(JSON.parse($('#app-query').text())).toEqual({
+ post: 'post-1',
+ hello: 'world',
+ })
+ expect($('#resolved-url').text()).toBe('/blog/post-1?hello=world')
+ expect($('#as-path').text()).toBe('/blog/post-1?hello=world')
+ })
+
+ it('should have correct req.url and query for direct visit', async () => {
+ const html = await renderViaHTTP(next.url, '/something')
+ const $ = cheerio.load(html)
+ expect($('#app-url').text()).toContain('/something')
+ expect(JSON.parse($('#app-query').text())).toEqual({})
+ expect($('#resolved-url').text()).toBe('/something')
+ expect($('#as-path').text()).toBe('/something')
+ })
+
+ it('should return data correctly', async () => {
+ const data = JSON.parse(
+ await renderViaHTTP(next.url, `/_next/data/${buildId}/something.json`)
+ )
+ expect(data.pageProps.world).toBe('world')
+ })
+
+ it('should pass query for data request', async () => {
+ const data = JSON.parse(
+ await renderViaHTTP(
+ next.url,
+ `/_next/data/${buildId}/something.json?another=thing`
+ )
+ )
+ expect(data.pageProps.query.another).toBe('thing')
+ })
+
+ it('should return data correctly for dynamic page', async () => {
+ const data = JSON.parse(
+ await renderViaHTTP(next.url, `/_next/data/${buildId}/blog/post-1.json`)
+ )
+ expect(data.pageProps.post).toBe('post-1')
+ })
+
+ it('should return data correctly when props is a promise', async () => {
+ const html = await renderViaHTTP(next.url, `/promise`)
+ expect(html).toMatch(/hello.*?promise/)
+ })
+
+ it('should navigate to a normal page and back', async () => {
+ const browser = await webdriver(next.url, '/')
+ let text = await browser.elementByCss('p').text()
+ expect(text).toMatch(/hello.*?world/)
+
+ await browser.elementByCss('#normal').click()
+ await browser.waitForElementByCss('#normal-text')
+ text = await browser.elementByCss('#normal-text').text()
+ expect(text).toMatch(/a normal page/)
+ })
+
+ it('should load a fast refresh page', async () => {
+ const browser = await webdriver(next.url, '/refresh')
+ expect(
+ await check(
+ () => browser.elementByCss('p').text(),
+ /client loaded/,
+ false
+ )
+ ).toBe(true)
+ })
+
+ it('should provide correct query value for dynamic page', async () => {
+ const html = await renderViaHTTP(
+ next.url,
+ '/blog/post-1?post=something-else'
+ )
+ const $ = cheerio.load(html)
+ const query = JSON.parse($('#query').text())
+ expect(query.post).toBe('post-1')
+ })
+
+ it('should parse query values on mount correctly', async () => {
+ const browser = await webdriver(next.url, '/blog/post-1?another=value')
+ await waitFor(2000)
+ const text = await browser.elementByCss('#query').text()
+ expect(text).toMatch(/another.*?value/)
+ expect(text).toMatch(/post.*?post-1/)
+ })
+
+ it('should pass query for data request on navigation', async () => {
+ const browser = await webdriver(next.url, '/')
+ await browser.eval('window.beforeNav = true')
+ await browser.elementByCss('#something-query').click()
+ await browser.waitForElementByCss('#initial-query')
+ const query = JSON.parse(
+ await browser.elementByCss('#initial-query').text()
+ )
+ expect(await browser.eval('window.beforeNav')).toBe(true)
+ expect(query.another).toBe('thing')
+ })
+
+ it('should reload page on failed data request', async () => {
+ const browser = await webdriver(next.url, '/')
+ await waitFor(500)
+ await browser.eval('window.beforeClick = "abc"')
+ await browser.elementByCss('#broken-post').click()
+ expect(
+ await check(() => browser.eval('window.beforeClick'), {
+ test(v) {
+ return v !== 'abc'
+ },
+ })
+ ).toBe(true)
+ })
+
+ it('should always call getServerSideProps without caching', async () => {
+ const initialRes = await fetchViaHTTP(next.url, '/something')
+ const initialHtml = await initialRes.text()
+ expect(initialHtml).toMatch(/hello.*?world/)
+
+ const newRes = await fetchViaHTTP(next.url, '/something')
+ const newHtml = await newRes.text()
+ expect(newHtml).toMatch(/hello.*?world/)
+ expect(initialHtml !== newHtml).toBe(true)
+
+ const newerRes = await fetchViaHTTP(next.url, '/something')
+ const newerHtml = await newerRes.text()
+ expect(newerHtml).toMatch(/hello.*?world/)
+ expect(newHtml !== newerHtml).toBe(true)
+ })
+
+ it('should not re-call getServerSideProps when updating query', async () => {
+ const browser = await webdriver(next.url, '/something?hello=world')
+ await waitFor(2000)
+
+ const query = await browser.elementByCss('#query').text()
+ expect(JSON.parse(query)).toEqual({ hello: 'world' })
+
+ const {
+ props: {
+ pageProps: { random: initialRandom },
+ },
+ } = await browser.eval('window.__NEXT_DATA__')
+
+ const curRandom = await browser.elementByCss('#random').text()
+ expect(curRandom).toBe(initialRandom + '')
+ })
+
+ it('should dedupe server data requests', async () => {
+ const browser = await webdriver(next.url, '/')
+ await waitFor(2000)
+
+ // Keep clicking on the link
+ await browser.elementByCss('#slow').click()
+ await browser.elementByCss('#slow').click()
+ await browser.elementByCss('#slow').click()
+ await browser.elementByCss('#slow').click()
+
+ await check(() => getBrowserBodyText(browser), /a slow page/)
+
+ // Requests should be deduped
+ const hitCount = await browser.elementByCss('#hit').text()
+ expect(hitCount).toBe('hit: 1')
+
+ // Should send request again
+ await browser.elementByCss('#home').click()
+ await browser.waitForElementByCss('#slow')
+ await browser.elementByCss('#slow').click()
+ await check(() => getBrowserBodyText(browser), /a slow page/)
+
+ const newHitCount = await browser.elementByCss('#hit').text()
+ expect(newHitCount).toBe('hit: 2')
+ })
+
+ if (isDev) {
+ it('should not show warning from url prop being returned', async () => {
+ const urlPropPage = 'pages/url-prop.js'
+ await next.patchFile(
+ urlPropPage,
+ `
+ export async function getServerSideProps() {
+ return {
+ props: {
+ url: 'something'
+ }
+ }
+ }
+
+ export default ({ url }) => url: {url}
+ `
+ )
+
+ const html = await renderViaHTTP(next.url, '/url-prop')
+ await next.deleteFile(urlPropPage)
+ expect(next.cliOutput).not.toMatch(
+ /The prop `url` is a reserved prop in Next.js for legacy reasons and will be overridden on page \/url-prop/
+ )
+ expect(html).toMatch(/url:.*?something/)
+ })
+
+ it('should show error for extra keys returned from getServerSideProps', async () => {
+ const html = await renderViaHTTP(next.url, '/invalid-keys')
+ expect(html).toContain(
+ `Additional keys were returned from \`getServerSideProps\`. Properties intended for your component must be nested under the \`props\` key, e.g.:`
+ )
+ expect(html).toContain(
+ `Keys that need to be moved: world, query, params, time, random`
+ )
+ })
+
+ it('should show error for invalid JSON returned from getServerSideProps', async () => {
+ const html = await renderViaHTTP(next.url, '/non-json')
+ expect(html).toContain(
+ 'Error serializing `.time` returned from `getServerSideProps`'
+ )
+ })
+
+ it('should show error for invalid JSON returned from getStaticProps on CST', async () => {
+ const browser = await webdriver(next.url, '/')
+ await browser.elementByCss('#non-json').click()
+
+ await check(
+ () => getRedboxHeader(browser),
+ /Error serializing `.time` returned from `getServerSideProps`/
+ )
+ })
+
+ it('should show error for accessing res after gssp returns', async () => {
+ const html = await renderViaHTTP(next.url, '/promise/mutate-res')
+ expect(html).toContain(
+ `You should not access 'res' after getServerSideProps resolves`
+ )
+ })
+
+ it('should show error for accessing res through props promise after gssp returns', async () => {
+ const html = await renderViaHTTP(next.url, '/promise/mutate-res-props')
+ expect(html).toContain(
+ `You should not access 'res' after getServerSideProps resolves`
+ )
+ })
+
+ it('should only warn for accessing res if not streaming', async () => {
+ const html = await renderViaHTTP(
+ next.url,
+ '/promise/mutate-res-no-streaming'
+ )
+ expect(html).not.toContain(
+ `You should not access 'res' after getServerSideProps resolves`
+ )
+ expect(next.cliOutput).toContain(
+ `You should not access 'res' after getServerSideProps resolves`
+ )
+ })
+ } else {
+ it('should not fetch data on mount', async () => {
+ const browser = await webdriver(next.url, '/blog/post-100')
+ await browser.eval('window.thisShouldStay = true')
+ await waitFor(2 * 1000)
+ const val = await browser.eval('window.thisShouldStay')
+ expect(val).toBe(true)
+ })
+
+ if (!isDeploy) {
+ it('should output routes-manifest correctly', async () => {
+ const { dataRoutes } = JSON.parse(
+ await next.readFile('.next/routes-manifest.json')
+ )
+ for (const route of dataRoutes) {
+ route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex)
+ }
+
+ expect(dataRoutes).toEqual(expectedManifestRoutes())
+ })
+ }
+
+ it('should set default caching header', async () => {
+ const resPage = await fetchViaHTTP(next.url, `/something`)
+ expect(resPage.headers.get('cache-control')).toBe(
+ 'private, no-cache, no-store, max-age=0, must-revalidate'
+ )
+
+ const resData = await fetchViaHTTP(
+ next.url,
+ `/_next/data/${buildId}/something.json`
+ )
+ expect(resData.headers.get('cache-control')).toBe(
+ 'private, no-cache, no-store, max-age=0, must-revalidate'
+ )
+ })
+
+ it('should respect custom caching header', async () => {
+ const resPage = await fetchViaHTTP(next.url, `/custom-cache`)
+ expect(resPage.headers.get('cache-control')).toBe('public, max-age=3600')
+
+ const resData = await fetchViaHTTP(
+ next.url,
+ `/_next/data/${buildId}/custom-cache.json`
+ )
+ expect(resData.headers.get('cache-control')).toBe('public, max-age=3600')
+ })
+
+ it('should not show error for invalid JSON returned from getServerSideProps', async () => {
+ const html = await renderViaHTTP(next.url, '/non-json')
+ expect(html).not.toContain('Error serializing')
+ expect(html).toContain('hello ')
+ })
+
+ it('should not show error for invalid JSON returned from getStaticProps on CST', async () => {
+ const browser = await webdriver(next.url, '/')
+ await browser.elementByCss('#non-json').click()
+ await check(() => getBrowserBodyText(browser), /hello /)
+ })
+
+ it('should not show error for accessing res after gssp returns', async () => {
+ const html = await renderViaHTTP(next.url, '/promise/mutate-res')
+ expect(html).toMatch(/hello.*?res/)
+ })
+
+ it('should not warn for accessing res after gssp returns', async () => {
+ const html = await renderViaHTTP(next.url, '/promise/mutate-res')
+ expect(html).toMatch(/hello.*?res/)
+ })
+ }
+}
+
+describe('getServerSideProps', () => {
+ beforeAll(async () => {
+ next = await createNext({
+ files: {
+ pages: new FileRef(join(appDir, 'pages')),
+ 'world.txt': new FileRef(join(appDir, 'world.txt')),
+ 'next.config.js': new FileRef(join(appDir, 'next.config.js')),
+ },
+ })
+ buildId = next.buildId
+ })
+ afterAll(() => next.destroy())
+
+ runTests((global as any).isNextDev, (global as any).isNextDeploy)
+})
diff --git a/test/e2e/jest.config.js b/test/e2e/jest.config.js
new file mode 100644
index 0000000000..b9fcbe1b58
--- /dev/null
+++ b/test/e2e/jest.config.js
@@ -0,0 +1,21 @@
+// @ts-check
+/** @type {import('@jest/types').Config.InitialOptions} */
+
+const config = {
+ rootDir: __dirname,
+ setupFiles: ['../../jestSetup.js'],
+ testMatch: ['**/test/**/*.test.js', '**/test/**/*.test.ts'],
+ transform: {
+ '\\.[jt]sx?$': 'babel-jest',
+ },
+ verbose: true,
+ testTimeout: 60000,
+ moduleNameMapper: {
+ 'e2e-utils': '/next-test-lib/e2e-utils.ts',
+ 'test/lib/next-modes/base': '/next-test-lib/next-modes/base.ts',
+ 'next-test-utils': '/next-test-lib/next-test-utils.js',
+ 'next-webdriver': '/next-test-lib/next-webdriver.ts',
+ },
+}
+
+module.exports = config
diff --git a/test/e2e/next-test-lib/README.md b/test/e2e/next-test-lib/README.md
new file mode 100644
index 0000000000..feb18f0c67
--- /dev/null
+++ b/test/e2e/next-test-lib/README.md
@@ -0,0 +1,4 @@
+# next-test-utils
+
+Adapted from [next-test-utils](https://github.com/vercel/next.js/blob/canary/test/lib).
+[License](https://github.com/vercel/next.js/blob/canary/license.md)
diff --git a/test/e2e/next-test-lib/amp-test-utils.js b/test/e2e/next-test-lib/amp-test-utils.js
new file mode 100644
index 0000000000..203addf321
--- /dev/null
+++ b/test/e2e/next-test-lib/amp-test-utils.js
@@ -0,0 +1,19 @@
+/* eslint-env jest */
+import amphtmlValidator from 'amphtml-validator'
+
+export async function validateAMP(html) {
+ const validator = await amphtmlValidator.getInstance()
+ const result = validator.validateString(html)
+ if (result.status !== 'PASS') {
+ for (let ii = 0; ii < result.errors.length; ii++) {
+ const error = result.errors[ii]
+ let msg =
+ 'line ' + error.line + ', col ' + error.col + ': ' + error.message
+ if (error.specUrl !== null) {
+ msg += ' (see ' + error.specUrl + ')'
+ }
+ ;(error.severity === 'ERROR' ? console.error : console.warn)(msg)
+ }
+ }
+ expect(result.status).toBe('PASS')
+}
diff --git a/test/e2e/next-test-lib/browsers/base.ts b/test/e2e/next-test-lib/browsers/base.ts
new file mode 100644
index 0000000000..748270aca3
--- /dev/null
+++ b/test/e2e/next-test-lib/browsers/base.ts
@@ -0,0 +1,111 @@
+export type Event = 'request'
+
+// This is the base Browser interface all browser
+// classes should build off of, it is the bare
+// methods we aim to support across tests
+export class BrowserInterface {
+ private promise: any
+ private then: any
+ private catch: any
+
+ protected chain(nextCall: any): BrowserInterface {
+ if (!this.promise) {
+ this.promise = Promise.resolve(this)
+ }
+ this.promise = this.promise.then(nextCall)
+ this.then = (...args) => this.promise.then(...args)
+ this.catch = (...args) => this.promise.catch(...args)
+ return this
+ }
+
+ async setup(browserName: string, locale?: string): Promise {}
+ async close(): Promise {}
+ async quit(): Promise {}
+
+ elementsByCss(selector: string): BrowserInterface[] {
+ return [this]
+ }
+ elementByCss(selector: string): BrowserInterface {
+ return this
+ }
+ elementById(selector: string): BrowserInterface {
+ return this
+ }
+ touchStart(): BrowserInterface {
+ return this
+ }
+ click(opts?: { modifierKey?: boolean }): BrowserInterface {
+ return this
+ }
+ keydown(key: string): BrowserInterface {
+ return this
+ }
+ keyup(key: string): BrowserInterface {
+ return this
+ }
+ focusPage(): BrowserInterface {
+ return this
+ }
+ type(text: string): BrowserInterface {
+ return this
+ }
+ moveTo(): BrowserInterface {
+ return this
+ }
+ waitForElementByCss(selector: string, timeout?: number): BrowserInterface {
+ return this
+ }
+ waitForCondition(snippet: string, timeout?: number): BrowserInterface {
+ return this
+ }
+ back(): BrowserInterface {
+ return this
+ }
+ forward(): BrowserInterface {
+ return this
+ }
+ refresh(): BrowserInterface {
+ return this
+ }
+ setDimensions(opts: { height: number; width: number }): BrowserInterface {
+ return this
+ }
+ addCookie(opts: { name: string; value: string }): BrowserInterface {
+ return this
+ }
+ deleteCookies(): BrowserInterface {
+ return this
+ }
+ on(event: Event, cb: (...args: any[]) => void) {}
+ off(event: Event, cb: (...args: any[]) => void) {}
+ async loadPage(
+ url: string,
+ { disableCache: boolean, beforePageLoad: Function }
+ ): Promise {}
+ async get(url: string): Promise {}
+
+ async getValue(): Promise {}
+ async getAttribute(name: string): Promise {}
+ async eval(snippet: string | Function): Promise {}
+ async evalAsync(snippet: string | Function): Promise {}
+ async text(): Promise {
+ return ''
+ }
+ async getComputedCss(prop: string): Promise {
+ return ''
+ }
+ async hasElementByCssSelector(selector: string): Promise {
+ return false
+ }
+ async log(): Promise<
+ { source: 'error' | 'info' | 'log'; message: string }[]
+ > {
+ return []
+ }
+ async websocketFrames(): Promise {
+ return []
+ }
+ async url(): Promise {
+ return ''
+ }
+}
diff --git a/test/e2e/next-test-lib/browsers/playwright.ts b/test/e2e/next-test-lib/browsers/playwright.ts
new file mode 100644
index 0000000000..ce7e9d86f3
--- /dev/null
+++ b/test/e2e/next-test-lib/browsers/playwright.ts
@@ -0,0 +1,346 @@
+import { BrowserInterface, Event } from './base'
+import fs from 'fs-extra'
+import { chromium, webkit, firefox, Browser, BrowserContext, Page, ElementHandle, devices } from 'playwright-chromium'
+import path from 'path'
+import destr from 'destr'
+let page: Page
+let browser: Browser
+let context: BrowserContext
+let pageLogs: Array<{ source: string; message: string }> = []
+let websocketFrames: Array<{ payload: string | Buffer }> = []
+
+const tracePlaywright = process.env.TRACE_PLAYWRIGHT
+
+export async function quit() {
+ await context?.close()
+ await browser?.close()
+}
+
+export class Playwright extends BrowserInterface {
+ private activeTrace?: string
+ private eventCallbacks: Record void>> = {
+ request: new Set(),
+ }
+
+ on(event: Event, cb: (...args: any[]) => void) {
+ if (!this.eventCallbacks[event]) {
+ throw new Error(`Invalid event passed to browser.on, received ${event}. Valid events are ${Object.keys(event)}`)
+ }
+ this.eventCallbacks[event]?.add(cb)
+ }
+ off(event: Event, cb: (...args: any[]) => void) {
+ this.eventCallbacks[event]?.delete(cb)
+ }
+
+ async setup(browserName: string, locale?: string) {
+ if (browser) return
+ // Headless by default
+ const headless = destr(process.env.HEADLESS) ?? true
+ let device
+
+ if (process.env.DEVICE_NAME) {
+ device = devices[process.env.DEVICE_NAME]
+
+ if (!device) {
+ throw new Error(`Invalid playwright device name ${process.env.DEVICE_NAME}`)
+ }
+ }
+
+ if (browserName === 'safari') {
+ browser = await webkit.launch({ headless })
+ } else if (browserName === 'firefox') {
+ browser = await firefox.launch({ headless })
+ } else {
+ browser = await chromium.launch({ headless, devtools: !headless })
+ }
+ context = await browser.newContext({ locale, ...device })
+ }
+
+ async get(url: string): Promise {
+ return page.goto(url) as any
+ }
+
+ async loadPage(url: string, opts?: { disableCache: boolean; beforePageLoad?: (...args: any[]) => void }) {
+ if (this.activeTrace) {
+ const traceDir = path.join(__dirname, '../../traces')
+ const traceOutputPath = path.join(
+ traceDir,
+ `${path.relative(path.join(__dirname, '../../'), process.env.TEST_FILE_PATH ?? '').replace(/\//g, '-')}`,
+ `playwright-${this.activeTrace}-${Date.now()}.zip`,
+ )
+
+ await fs.remove(traceOutputPath)
+ await context.tracing
+ .stop({
+ path: traceOutputPath,
+ })
+ .catch((err) => console.error('failed to write playwright trace', err))
+ }
+
+ // clean-up existing pages
+ for (const oldPage of context.pages()) {
+ await oldPage.close()
+ }
+ page = await context.newPage()
+ pageLogs = []
+ websocketFrames = []
+
+ page.on('console', (msg) => {
+ console.log('browser log:', msg)
+ pageLogs.push({ source: msg.type(), message: msg.text() })
+ })
+ page.on('crash', (page) => {
+ console.error('page crashed')
+ })
+ page.on('pageerror', (error) => {
+ console.error('page error', error)
+ })
+ page.on('request', (req) => {
+ this.eventCallbacks.request.forEach((cb) => cb(req))
+ })
+
+ if (opts?.disableCache) {
+ // TODO: this doesn't seem to work (dev tools does not check the box as expected)
+ const session = await context.newCDPSession(page)
+ session.send('Network.setCacheDisabled', { cacheDisabled: true })
+ }
+
+ page.on('websocket', (ws) => {
+ if (tracePlaywright) {
+ page.evaluate(`console.log('connected to ws at ${ws.url()}')`).catch(() => {})
+
+ ws.on('close', () => page.evaluate(`console.log('closed websocket ${ws.url()}')`).catch(() => {}))
+ }
+ ws.on('framereceived', (frame) => {
+ websocketFrames.push({ payload: frame.payload })
+
+ if (tracePlaywright) {
+ if (!frame.payload.includes('pong')) {
+ page.evaluate(`console.log('received ws message ${frame.payload}')`).catch(() => {})
+ }
+ }
+ })
+ })
+
+ opts?.beforePageLoad?.(page)
+
+ if (tracePlaywright) {
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ })
+ this.activeTrace = encodeURIComponent(url)
+ }
+ await page.goto(url, { waitUntil: 'load' })
+ }
+
+ back(): BrowserInterface {
+ return this.chain(() => {
+ return page.goBack()
+ })
+ }
+ forward(): BrowserInterface {
+ return this.chain(() => {
+ return page.goForward()
+ })
+ }
+ refresh(): BrowserInterface {
+ return this.chain(() => {
+ return page.reload()
+ })
+ }
+ setDimensions({ width, height }: { height: number; width: number }): BrowserInterface {
+ return this.chain(() => page.setViewportSize({ width, height }))
+ }
+ addCookie(opts: { name: string; value: string }): BrowserInterface {
+ return this.chain(async () =>
+ context.addCookies([
+ {
+ path: '/',
+ domain: await page.evaluate('window.location.hostname'),
+ ...opts,
+ },
+ ]),
+ )
+ }
+ deleteCookies(): BrowserInterface {
+ return this.chain(async () => context.clearCookies())
+ }
+
+ focusPage() {
+ return this.chain(() => page.bringToFront())
+ }
+
+ private wrapElement(el: ElementHandle, selector: string) {
+ ;(el as any).selector = selector
+ ;(el as any).text = () => el.innerText()
+ ;(el as any).getComputedCss = (prop) =>
+ page.evaluate(
+ function (args) {
+ return getComputedStyle(document.querySelector(args.selector)!)[args.prop] || null
+ },
+ { selector, prop },
+ )
+ ;(el as any).getCssValue = (el as any).getComputedCss
+ ;(el as any).getValue = () =>
+ page.evaluate(
+ function (args) {
+ return (document.querySelector(args.selector) as any).value
+ },
+ { selector },
+ )
+ return el
+ }
+
+ elementByCss(selector: string) {
+ return this.waitForElementByCss(selector)
+ }
+
+ elementById(sel) {
+ return this.elementByCss(`#${sel}`)
+ }
+
+ getValue() {
+ return this.chain((el) =>
+ page.evaluate(
+ function (args) {
+ return document.querySelector(args.selector).value
+ },
+ { selector: el.selector },
+ ),
+ ) as any
+ }
+
+ text() {
+ return this.chain((el) => el.text()) as any
+ }
+
+ type(text) {
+ return this.chain((el) => el.type(text))
+ }
+
+ moveTo() {
+ return this.chain((el) => {
+ return page.hover(el.selector).then(() => el)
+ })
+ }
+
+ async getComputedCss(prop: string) {
+ return this.chain((el) => {
+ return el.getCssValue(prop)
+ }) as any
+ }
+
+ async getAttribute(attr) {
+ return this.chain((el) => el.getAttribute(attr))
+ }
+
+ async hasElementByCssSelector(selector: string) {
+ return this.eval(`!!document.querySelector('${selector}')`) as any
+ }
+
+ keydown(key: string): BrowserInterface {
+ return this.chain((el) => {
+ return page.keyboard.down(key).then(() => el)
+ })
+ }
+
+ keyup(key: string): BrowserInterface {
+ return this.chain((el) => {
+ return page.keyboard.up(key).then(() => el)
+ })
+ }
+
+ click() {
+ return this.chain((el) => {
+ return el.click().then(() => el)
+ })
+ }
+
+ touchStart() {
+ return this.chain((el: ElementHandle) => {
+ return el.dispatchEvent('touchstart').then(() => el)
+ })
+ }
+
+ elementsByCss(sel) {
+ return this.chain(() =>
+ page.$$(sel).then((els) => {
+ return els.map((el) => {
+ const origGetAttribute = el.getAttribute.bind(el)
+ el.getAttribute = (name) => {
+ // ensure getAttribute defaults to empty string to
+ // match selenium
+ return origGetAttribute(name).then((val) => val || '')
+ }
+ return el
+ })
+ }),
+ ) as any as BrowserInterface[]
+ }
+
+ waitForElementByCss(selector, timeout?: number) {
+ return this.chain(() => {
+ return page.waitForSelector(selector, { timeout, state: 'attached' }).then(async (el) => {
+ // it seems selenium waits longer and tests rely on this behavior
+ // so we wait for the load event fire before returning
+ await page.waitForLoadState()
+ return this.wrapElement(el, selector)
+ })
+ })
+ }
+
+ waitForCondition(condition, timeout) {
+ return this.chain(() => {
+ return page.waitForFunction(condition, { timeout })
+ })
+ }
+
+ async eval(snippet) {
+ // TODO: should this and evalAsync be chained? Might lead
+ // to bad chains
+ return page
+ .evaluate(snippet)
+ .catch((err) => {
+ console.error('eval error:', err)
+ return null
+ })
+ .then(async (val) => {
+ await page.waitForLoadState()
+ return val
+ })
+ }
+
+ async evalAsync(snippet) {
+ if (typeof snippet === 'function') {
+ snippet = snippet.toString()
+ }
+
+ if (snippet.includes(`var callback = arguments[arguments.length - 1]`)) {
+ snippet = `(function() {
+ return new Promise((resolve, reject) => {
+ const origFunc = ${snippet}
+ try {
+ origFunc(resolve)
+ } catch (err) {
+ reject(err)
+ }
+ })
+ })()`
+ }
+
+ return page.evaluate(snippet).catch(() => null)
+ }
+
+ async log() {
+ return this.chain(() => pageLogs) as any
+ }
+
+ async websocketFrames() {
+ return this.chain(() => websocketFrames) as any
+ }
+
+ async url() {
+ return this.chain(() => page.evaluate('window.location.href')) as any
+ }
+}
diff --git a/test/e2e/next-test-lib/browsers/selenium.ts b/test/e2e/next-test-lib/browsers/selenium.ts
new file mode 100644
index 0000000000..e6e8c1aa00
--- /dev/null
+++ b/test/e2e/next-test-lib/browsers/selenium.ts
@@ -0,0 +1,358 @@
+import path from 'path'
+import resolveFrom from 'resolve-from'
+import { execSync } from 'child_process'
+import { Options as ChromeOptions } from 'selenium-webdriver/chrome'
+import { Options as SafariOptions } from 'selenium-webdriver/safari'
+import { Options as FireFoxOptions } from 'selenium-webdriver/firefox'
+import { Builder, By, ThenableWebDriver, until } from 'selenium-webdriver'
+
+import { BrowserInterface } from './base'
+
+const {
+ BROWSERSTACK,
+ BROWSERSTACK_USERNAME,
+ BROWSERSTACK_ACCESS_KEY,
+ HEADLESS,
+ CHROME_BIN,
+ LEGACY_SAFARI,
+ SKIP_LOCAL_SELENIUM_SERVER,
+} = process.env
+
+if (process.env.ChromeWebDriver) {
+ process.env.PATH = `${process.env.ChromeWebDriver}${path.delimiter}${process.env.PATH}`
+}
+
+let seleniumServer: any
+let browserStackLocal: any
+let browser: ThenableWebDriver
+
+export async function quit() {
+ await Promise.all([
+ browser?.quit(),
+ new Promise((resolve) => {
+ browserStackLocal
+ ? browserStackLocal.killAllProcesses(() => resolve())
+ : resolve()
+ }),
+ ])
+ seleniumServer?.kill()
+
+ browser = undefined
+ browserStackLocal = undefined
+ seleniumServer = undefined
+}
+
+export class Selenium extends BrowserInterface {
+ private browserName: string
+
+ // TODO: support setting locale
+ async setup(browserName: string, locale?: string) {
+ if (browser) return
+ this.browserName = browserName
+
+ let capabilities = {}
+ const isSafari = browserName === 'safari'
+ const isFirefox = browserName === 'firefox'
+ const isIE = browserName === 'internet explorer'
+ const isBrowserStack = BROWSERSTACK
+ const localSeleniumServer = SKIP_LOCAL_SELENIUM_SERVER !== 'true'
+
+ // install conditional packages globally so the entire
+ // monorepo doesn't need to rebuild when testing
+ let globalNodeModules: string
+
+ if (isBrowserStack || localSeleniumServer) {
+ globalNodeModules = execSync('npm root -g').toString().trim()
+ }
+
+ if (isBrowserStack) {
+ const { Local } = require(resolveFrom(
+ globalNodeModules,
+ 'browserstack-local'
+ ))
+ browserStackLocal = new Local()
+
+ const localBrowserStackOpts = {
+ key: process.env.BROWSERSTACK_ACCESS_KEY,
+ // Add a unique local identifier to run parallel tests
+ // on BrowserStack
+ localIdentifier: new Date().getTime(),
+ }
+ await new Promise((resolve, reject) => {
+ browserStackLocal.start(localBrowserStackOpts, (err) => {
+ if (err) return reject(err)
+ console.log(
+ 'Started BrowserStackLocal',
+ browserStackLocal.isRunning()
+ )
+ resolve()
+ })
+ })
+
+ const safariOpts = {
+ os: 'OS X',
+ os_version: 'Mojave',
+ browser: 'Safari',
+ }
+ const safariLegacyOpts = {
+ os: 'OS X',
+ os_version: 'Sierra',
+ browserName: 'Safari',
+ browser_version: '10.1',
+ }
+ const ieOpts = {
+ os: 'Windows',
+ os_version: '10',
+ browser: 'IE',
+ }
+ const firefoxOpts = {
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Firefox',
+ }
+ const sharedOpts = {
+ 'browserstack.local': true,
+ 'browserstack.video': false,
+ 'browserstack.user': BROWSERSTACK_USERNAME,
+ 'browserstack.key': BROWSERSTACK_ACCESS_KEY,
+ 'browserstack.localIdentifier': localBrowserStackOpts.localIdentifier,
+ }
+
+ capabilities = {
+ ...capabilities,
+ ...sharedOpts,
+
+ ...(isIE ? ieOpts : {}),
+ ...(isSafari ? (LEGACY_SAFARI ? safariLegacyOpts : safariOpts) : {}),
+ ...(isFirefox ? firefoxOpts : {}),
+ }
+ } else if (localSeleniumServer) {
+ console.log('Installing selenium server')
+ const seleniumServerMod = require(resolveFrom(
+ globalNodeModules,
+ 'selenium-standalone'
+ ))
+
+ await new Promise((resolve, reject) => {
+ seleniumServerMod.install((err) => {
+ if (err) return reject(err)
+ resolve()
+ })
+ })
+
+ console.log('Starting selenium server')
+ await new Promise((resolve, reject) => {
+ seleniumServerMod.start((err, child) => {
+ if (err) return reject(err)
+ seleniumServer = child
+ resolve()
+ })
+ })
+ console.log('Started selenium server')
+ }
+
+ let chromeOptions = new ChromeOptions()
+ let firefoxOptions = new FireFoxOptions()
+ let safariOptions = new SafariOptions()
+
+ if (HEADLESS) {
+ const screenSize = { width: 1280, height: 720 }
+ chromeOptions = chromeOptions.headless().windowSize(screenSize) as any
+ firefoxOptions = firefoxOptions.headless().windowSize(screenSize)
+ }
+
+ if (CHROME_BIN) {
+ chromeOptions = chromeOptions.setChromeBinaryPath(
+ path.resolve(CHROME_BIN)
+ )
+ }
+
+ let seleniumServerUrl
+
+ if (isBrowserStack) {
+ seleniumServerUrl = 'http://hub-cloud.browserstack.com/wd/hub'
+ } else if (localSeleniumServer) {
+ seleniumServerUrl = `http://localhost:4444/wd/hub`
+ }
+
+ browser = new Builder()
+ .usingServer(seleniumServerUrl)
+ .withCapabilities(capabilities)
+ .forBrowser(browserName)
+ .setChromeOptions(chromeOptions)
+ .setFirefoxOptions(firefoxOptions)
+ .setSafariOptions(safariOptions)
+ .build()
+ }
+
+ async get(url: string): Promise {
+ return browser.get(url)
+ }
+
+ async loadPage(url: string) {
+ // in chrome we use a new tab for testing
+ if (this.browserName === 'chrome') {
+ const initialHandle = await browser.getWindowHandle()
+
+ await browser.switchTo().newWindow('tab')
+ const newHandle = await browser.getWindowHandle()
+
+ await browser.switchTo().window(initialHandle)
+ await browser.close()
+ await browser.switchTo().window(newHandle)
+
+ // clean-up extra windows created from links and such
+ for (const handle of await browser.getAllWindowHandles()) {
+ if (handle !== newHandle) {
+ await browser.switchTo().window(handle)
+ await browser.close()
+ }
+ }
+ await browser.switchTo().window(newHandle)
+ } else {
+ await browser.get('about:blank')
+ }
+ return browser.get(url)
+ }
+
+ back(): BrowserInterface {
+ return this.chain(() => {
+ return browser.navigate().back()
+ })
+ }
+ forward(): BrowserInterface {
+ return this.chain(() => {
+ return browser.navigate().forward()
+ })
+ }
+ refresh(): BrowserInterface {
+ return this.chain(() => {
+ return browser.navigate().refresh()
+ })
+ }
+ setDimensions({
+ width,
+ height,
+ }: {
+ height: number
+ width: number
+ }): BrowserInterface {
+ return this.chain(() =>
+ browser.manage().window().setRect({ width, height, x: 0, y: 0 })
+ )
+ }
+ addCookie(opts: { name: string; value: string }): BrowserInterface {
+ return this.chain(() => browser.manage().addCookie(opts))
+ }
+ deleteCookies(): BrowserInterface {
+ return this.chain(() => browser.manage().deleteAllCookies())
+ }
+
+ elementByCss(selector: string) {
+ return this.chain(() => {
+ return browser.findElement(By.css(selector)).then((el: any) => {
+ el.selector = selector
+ el.text = () => el.getText()
+ el.getComputedCss = (prop) => el.getCssValue(prop)
+ el.type = (text) => el.sendKeys(text)
+ el.getValue = () =>
+ browser.executeScript(
+ `return document.querySelector('${selector}').value`
+ )
+ return el
+ })
+ })
+ }
+
+ elementById(sel) {
+ return this.elementByCss(`#${sel}`)
+ }
+
+ getValue() {
+ return this.chain((el) =>
+ browser.executeScript(
+ `return document.querySelector('${el.selector}').value`
+ )
+ ) as any
+ }
+
+ text() {
+ return this.chain((el) => el.getText()) as any
+ }
+
+ type(text) {
+ return this.chain((el) => el.sendKeys(text))
+ }
+
+ moveTo() {
+ return this.chain((el) => {
+ return browser
+ .actions()
+ .move({ origin: el })
+ .perform()
+ .then(() => el)
+ })
+ }
+
+ async getComputedCss(prop: string) {
+ return this.chain((el) => {
+ return el.getCssValue(prop)
+ }) as any
+ }
+
+ async getAttribute(attr) {
+ return this.chain((el) => el.getAttribute(attr))
+ }
+
+ async hasElementByCssSelector(selector: string) {
+ return this.eval(`!!document.querySelector('${selector}')`) as any
+ }
+
+ click() {
+ return this.chain((el) => {
+ return el.click().then(() => el)
+ })
+ }
+
+ elementsByCss(sel) {
+ return this.chain(() =>
+ browser.findElements(By.css(sel))
+ ) as any as BrowserInterface[]
+ }
+
+ waitForElementByCss(sel, timeout) {
+ return this.chain(() =>
+ browser.wait(until.elementLocated(By.css(sel)), timeout)
+ )
+ }
+
+ waitForCondition(condition, timeout) {
+ return this.chain(() =>
+ browser.wait(async (driver) => {
+ return driver.executeScript('return ' + condition).catch(() => false)
+ }, timeout)
+ )
+ }
+
+ async eval(snippet) {
+ if (typeof snippet === 'string' && !snippet.startsWith('return')) {
+ snippet = `return ${snippet}`
+ }
+ return browser.executeScript(snippet)
+ }
+
+ async evalAsync(snippet) {
+ if (typeof snippet === 'string' && !snippet.startsWith('return')) {
+ snippet = `return ${snippet}`
+ }
+ return browser.executeAsyncScript(snippet)
+ }
+
+ async log() {
+ return this.chain(() => browser.manage().logs().get('browser')) as any
+ }
+
+ async url() {
+ return this.chain(() => browser.getCurrentUrl()) as any
+ }
+}
diff --git a/test/e2e/next-test-lib/create-next-install.js b/test/e2e/next-test-lib/create-next-install.js
new file mode 100644
index 0000000000..160081627e
--- /dev/null
+++ b/test/e2e/next-test-lib/create-next-install.js
@@ -0,0 +1,101 @@
+const os = require('os')
+const path = require('path')
+const execa = require('execa')
+const fs = require('fs-extra')
+const childProcess = require('child_process')
+const { randomBytes } = require('crypto')
+
+async function createNextInstall(dependencies, installCommand, packageJson = {}, packageLockPath = '') {
+ const tmpDir = await fs.realpath(process.env.NEXT_TEST_DIR || os.tmpdir())
+ const origRepoDir = path.join(__dirname, '../../')
+ const installDir = path.join(tmpDir, `next-install-${randomBytes(32).toString('hex')}`)
+ const tmpRepoDir = path.join(tmpDir, `next-repo-${randomBytes(32).toString('hex')}`)
+
+ // ensure swc binary is present in the native folder if
+ // not already built
+ for (const folder of await fs.readdir(path.join(origRepoDir, 'node_modules/@next'))) {
+ if (folder.startsWith('swc-')) {
+ const swcPkgPath = path.join(origRepoDir, 'node_modules/@next', folder)
+ const outputPath = path.join(origRepoDir, 'packages/next-swc/native')
+ await fs.copy(swcPkgPath, outputPath, {
+ filter: (item) => {
+ return (
+ item === swcPkgPath ||
+ (item.endsWith('.node') && !fs.pathExistsSync(path.join(outputPath, path.basename(item))))
+ )
+ },
+ })
+ }
+ }
+
+ for (const item of ['package.json', 'packages']) {
+ await fs.copy(path.join(origRepoDir, item), path.join(tmpRepoDir, item), {
+ filter: (item) => {
+ return (
+ !item.includes('node_modules') &&
+ !item.includes('.DS_Store') &&
+ // Exclude Rust compilation files
+ !/next[\\/]build[\\/]swc[\\/]target/.test(item) &&
+ !/next-swc[\\/]target/.test(item)
+ )
+ },
+ })
+ }
+
+ let combinedDependencies = dependencies
+
+ if (!(packageJson && packageJson.nextPrivateSkipLocalDeps)) {
+ const pkgPaths = await linkPackages(tmpRepoDir)
+ combinedDependencies = {
+ next: pkgPaths.get('next'),
+ ...Object.keys(dependencies).reduce((prev, pkg) => {
+ const pkgPath = pkgPaths.get(pkg)
+ prev[pkg] = pkgPath || dependencies[pkg]
+ return prev
+ }, {}),
+ }
+ }
+
+ await fs.ensureDir(installDir)
+ await fs.writeFile(
+ path.join(installDir, 'package.json'),
+ JSON.stringify(
+ {
+ ...packageJson,
+ dependencies: combinedDependencies,
+ private: true,
+ },
+ null,
+ 2,
+ ),
+ )
+
+ if (packageLockPath) {
+ await fs.copy(packageLockPath, path.join(installDir, path.basename(packageLockPath)))
+ }
+
+ if (installCommand) {
+ const installString =
+ typeof installCommand === 'function' ? installCommand({ dependencies: combinedDependencies }) : installCommand
+
+ console.log('running install command', installString)
+
+ childProcess.execSync(installString, {
+ cwd: installDir,
+ stdio: ['ignore', 'inherit', 'inherit'],
+ })
+ } else {
+ await execa('pnpm', ['install', '--strict-peer-dependencies=false'], {
+ cwd: installDir,
+ stdio: ['ignore', 'inherit', 'inherit'],
+ env: process.env,
+ })
+ }
+
+ await fs.remove(tmpRepoDir)
+ return installDir
+}
+
+module.exports = {
+ createNextInstall,
+}
diff --git a/test/e2e/next-test-lib/e2e-utils.ts b/test/e2e/next-test-lib/e2e-utils.ts
new file mode 100644
index 0000000000..f7f71132d4
--- /dev/null
+++ b/test/e2e/next-test-lib/e2e-utils.ts
@@ -0,0 +1,139 @@
+import path from 'path'
+import assert from 'assert'
+import { NextConfig } from 'next'
+import { InstallCommand, NextInstance, PackageJson } from './next-modes/base'
+
+import { NextDeployInstance } from './next-modes/next-deploy'
+
+// increase timeout to account for yarn install time
+jest.setTimeout(240 * 1000)
+
+const testsFolder = path.join(__dirname, '..')
+
+let testFile
+const testFileRegex = /\.test\.(js|tsx?)/
+
+const visitedModules = new Set()
+const checkParent = (mod) => {
+ if (!mod?.parent || visitedModules.has(mod)) return
+ testFile = mod.parent.filename || ''
+ visitedModules.add(mod)
+
+ if (!testFileRegex.test(testFile)) {
+ checkParent(mod.parent)
+ }
+}
+checkParent(module)
+
+process.env.TEST_FILE_PATH = testFile
+
+let testMode = 'deploy'
+
+if (!testFileRegex.test(testFile)) {
+ throw new Error(`e2e-utils imported from non-test file ${testFile} (must end with .test.(js,ts,tsx)`)
+}
+
+const testFolderModes = ['e2e', 'development', 'production']
+
+const testModeFromFile = testFolderModes.find((mode) => testFile.startsWith(path.join(testsFolder, mode)))
+
+if (testModeFromFile === 'e2e') {
+ const validE2EModes = ['dev', 'start', 'deploy']
+
+ if (!process.env.NEXT_TEST_JOB && !testMode) {
+ require('console').warn('Warn: no NEXT_TEST_MODE set, using default of start')
+ testMode = 'start'
+ }
+ assert(
+ validE2EModes.includes(testMode),
+ `NEXT_TEST_MODE must be one of ${validE2EModes.join(', ')} for e2e tests but received ${testMode}`,
+ )
+} else if (testModeFromFile === 'development') {
+ testMode = 'dev'
+} else if (testModeFromFile === 'production') {
+ testMode = 'start'
+}
+
+if (testMode === 'dev') {
+ ;(global as any).isNextDev = true
+} else if (testMode === 'deploy') {
+ ;(global as any).isNextDeploy = true
+} else {
+ ;(global as any).isNextStart = true
+}
+
+if (!testMode) {
+ throw new Error(`No 'NEXT_TEST_MODE' set in environment, this is required for e2e-utils`)
+}
+require('console').warn(`Using test mode: ${testMode} in test folder ${testModeFromFile}`)
+
+/**
+ * FileRef is wrapper around a file path that is meant be copied
+ * to the location where the next instance is being created
+ */
+export class FileRef {
+ public fsPath: string
+
+ constructor(path: string) {
+ this.fsPath = path
+ }
+}
+
+let nextInstance: NextInstance | undefined = undefined
+
+if (typeof afterAll === 'function') {
+ afterAll(async () => {
+ if (nextInstance) {
+ await nextInstance.destroy()
+ throw new Error(
+ `next instance not destroyed before exiting, make sure to call .destroy() after the tests after finished`,
+ )
+ }
+ })
+}
+
+/**
+ * Sets up and manages a Next.js instance in the configured
+ * test mode. The next instance will be isolated from the monorepo
+ * to prevent relying on modules that shouldn't be
+ */
+export async function createNext(opts: {
+ files:
+ | FileRef
+ | {
+ [filename: string]: string | FileRef
+ }
+ dependencies?: {
+ [name: string]: string
+ }
+ nextConfig?: NextConfig
+ skipStart?: boolean
+ installCommand?: InstallCommand
+ buildCommand?: string
+ packageJson?: PackageJson
+ startCommand?: string
+ packageLockPath?: string
+ env?: Record
+}): Promise {
+ try {
+ if (nextInstance) {
+ throw new Error(`createNext called without destroying previous instance`)
+ }
+
+ nextInstance = new NextDeployInstance(opts)
+
+ nextInstance.on('destroy', () => {
+ nextInstance = undefined
+ })
+
+ await nextInstance.setup()
+
+ return nextInstance!
+ } catch (err) {
+ require('console').error('Failed to create next instance', err)
+ try {
+ nextInstance.destroy()
+ } catch (_) {}
+ process.exit(1)
+ }
+}
diff --git a/test/e2e/next-test-lib/flat-map-polyfill.js b/test/e2e/next-test-lib/flat-map-polyfill.js
new file mode 100644
index 0000000000..b9ad706cfe
--- /dev/null
+++ b/test/e2e/next-test-lib/flat-map-polyfill.js
@@ -0,0 +1,37 @@
+if (!Array.prototype.flat) {
+ // eslint-disable-next-line no-extend-native
+ Object.defineProperty(Array.prototype, 'flat', {
+ configurable: true,
+ value: function flat() {
+ var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0])
+
+ return depth
+ ? Array.prototype.reduce.call(
+ this,
+ function (acc, cur) {
+ if (Array.isArray(cur)) {
+ acc.push.apply(acc, flat.call(cur, depth - 1))
+ } else {
+ acc.push(cur)
+ }
+
+ return acc
+ },
+ []
+ )
+ : Array.prototype.slice.call(this)
+ },
+ writable: true,
+ })
+}
+
+if (!Array.prototype.flatMap) {
+ // eslint-disable-next-line no-extend-native
+ Object.defineProperty(Array.prototype, 'flatMap', {
+ configurable: true,
+ value: function flatMap() {
+ return Array.prototype.map.apply(this, arguments).flat()
+ },
+ writable: true,
+ })
+}
diff --git a/test/e2e/next-test-lib/mocks-require-hook.js b/test/e2e/next-test-lib/mocks-require-hook.js
new file mode 100644
index 0000000000..7c5eac8563
--- /dev/null
+++ b/test/e2e/next-test-lib/mocks-require-hook.js
@@ -0,0 +1,24 @@
+const mod = require('module')
+
+const hookPropertyMap = new Map([
+ [
+ /node-polyfill-web-streams/,
+ require.resolve('../__mocks__/node-polyfill-web-streams.js'),
+ ],
+])
+
+function matchModule(request) {
+ for (const [key, value] of hookPropertyMap) {
+ if (key.test(request)) {
+ return value
+ }
+ }
+ return null
+}
+
+const resolveFilename = mod._resolveFilename
+mod._resolveFilename = function (request, parent, isMain, options) {
+ const hookResolved = matchModule(request)
+ if (hookResolved) request = hookResolved
+ return resolveFilename.call(mod, request, parent, isMain, options)
+}
diff --git a/test/e2e/next-test-lib/next-modes/base.ts b/test/e2e/next-test-lib/next-modes/base.ts
new file mode 100644
index 0000000000..7d4802c54a
--- /dev/null
+++ b/test/e2e/next-test-lib/next-modes/base.ts
@@ -0,0 +1,295 @@
+import os from 'os'
+import path, { dirname } from 'path'
+import fs from 'fs-extra'
+import { NextConfig } from 'next'
+import { FileRef } from '../e2e-utils'
+import { ChildProcess } from 'child_process'
+import { createNextInstall } from '../create-next-install'
+
+type Event = 'stdout' | 'stderr' | 'error' | 'destroy'
+export type InstallCommand = string | ((ctx: { dependencies: { [key: string]: string } }) => string)
+
+export type PackageJson = {
+ [key: string]: unknown
+}
+export class NextInstance {
+ protected files:
+ | FileRef
+ | {
+ [filename: string]: string | FileRef
+ }
+ protected nextConfig?: NextConfig
+ protected installCommand?: InstallCommand
+ protected buildCommand?: string
+ protected startCommand?: string
+ protected dependencies?: { [name: string]: string }
+ protected events: { [eventName: string]: Set }
+ public testDir: string
+ protected isStopping: boolean
+ protected isDestroyed: boolean
+ protected childProcess: ChildProcess
+ protected _url: string
+ protected _parsedUrl: URL
+ protected packageJson: PackageJson
+ protected packageLockPath?: string
+ protected basePath?: string
+ protected env?: Record
+ public forcedPort?: string
+
+ constructor({
+ files,
+ dependencies,
+ nextConfig,
+ installCommand,
+ buildCommand,
+ startCommand,
+ packageJson = {},
+ packageLockPath,
+ env,
+ }: {
+ files:
+ | FileRef
+ | {
+ [filename: string]: string | FileRef
+ }
+ dependencies?: {
+ [name: string]: string
+ }
+ packageJson?: PackageJson
+ packageLockPath?: string
+ nextConfig?: NextConfig
+ installCommand?: InstallCommand
+ buildCommand?: string
+ startCommand?: string
+ env?: Record
+ }) {
+ this.files = files
+ this.dependencies = dependencies
+ this.nextConfig = nextConfig
+ this.installCommand = installCommand
+ this.buildCommand = buildCommand
+ this.startCommand = startCommand
+ this.packageJson = packageJson
+ this.packageLockPath = packageLockPath
+ this.events = {}
+ this.isDestroyed = false
+ this.isStopping = false
+ this.env = env
+ }
+
+ protected async createTestDir({ skipInstall = false }: { skipInstall?: boolean } = {}) {
+ if (this.isDestroyed) {
+ throw new Error('next instance already destroyed')
+ }
+
+ const tmpDir = process.env.NEXT_TEST_DIR || (await fs.realpath(os.tmpdir()))
+ this.testDir = path.join(tmpDir, `next-test-${Date.now()}-${(Math.random() * 1000) | 0}`)
+
+ const finalDependencies = {
+ react: 'latest',
+ 'react-dom': 'latest',
+ ...this.dependencies,
+ ...((this.packageJson.dependencies as object | undefined) || {}),
+ }
+
+ const plugin = dirname(require.resolve('@netlify/plugin-nextjs/package.json'))
+
+ const pkgScripts = (this.packageJson['scripts'] as {}) || {}
+ await fs.ensureDir(this.testDir)
+ await fs.writeFile(
+ path.join(this.testDir, 'package.json'),
+ JSON.stringify(
+ {
+ ...this.packageJson,
+ license: 'MIT',
+ dependencies: {
+ ...finalDependencies,
+ '@netlify/plugin-nextjs': `file:${plugin}`,
+ next: process.env.NEXT_TEST_VERSION || require('next/package.json').version,
+ },
+ scripts: {
+ ...pkgScripts,
+ },
+ },
+ null,
+ 2,
+ ),
+ )
+
+ if (this.files instanceof FileRef) {
+ // if a FileRef is passed directly to `files` we copy the
+ // entire folder to the test directory
+ const stats = await fs.stat(this.files.fsPath)
+
+ if (!stats.isDirectory()) {
+ throw new Error(`FileRef passed to "files" in "createNext" is not a directory ${this.files.fsPath}`)
+ }
+ await fs.copy(this.files.fsPath, this.testDir)
+ } else {
+ for (const filename of Object.keys(this.files)) {
+ const item = this.files[filename]
+ const outputFilename = path.join(this.testDir, filename)
+
+ if (typeof item === 'string') {
+ await fs.ensureDir(path.dirname(outputFilename))
+ await fs.writeFile(outputFilename, item)
+ } else {
+ await fs.copy(item.fsPath, outputFilename)
+ }
+ }
+ }
+
+ if (!fs.existsSync(path.join(this.testDir, 'netlify.toml'))) {
+ const toml = /* toml */ `
+ [build]
+ command = "next build"
+ publish = ".next"
+
+ [[plugins]]
+ package = "@netlify/plugin-nextjs"
+ `
+
+ await fs.writeFile(path.join(this.testDir, 'netlify.toml'), toml)
+ }
+
+ let nextConfigFile = Object.keys(this.files).find((file) => file.startsWith('next.config.'))
+
+ if (await fs.pathExists(path.join(this.testDir, 'next.config.js'))) {
+ nextConfigFile = 'next.config.js'
+ }
+
+ if (nextConfigFile && this.nextConfig) {
+ throw new Error(
+ `nextConfig provided on "createNext()" and as a file "${nextConfigFile}", use one or the other to continue`,
+ )
+ }
+
+ if (this.nextConfig || ((global as any).isNextDeploy && !nextConfigFile)) {
+ const functions = []
+
+ await fs.writeFile(
+ path.join(this.testDir, 'next.config.js'),
+ `
+ module.exports = ` +
+ JSON.stringify(
+ {
+ ...this.nextConfig,
+ } as NextConfig,
+ (key, val) => {
+ if (typeof val === 'function') {
+ functions.push(val.toString().replace(new RegExp(`${val.name}[\\s]{0,}\\(`), 'function('))
+ return `__func_${functions.length - 1}`
+ }
+ return val
+ },
+ 2,
+ ).replace(/"__func_[\d]{1,}"/g, function (str) {
+ return functions.shift()
+ }),
+ )
+ }
+
+ if ((global as any).isNextDeploy) {
+ const fileName = path.join(this.testDir, nextConfigFile || 'next.config.js')
+ const content = await fs.readFile(fileName, 'utf8')
+
+ if (content.includes('basePath')) {
+ this.basePath = content.match(/['"`]?basePath['"`]?:.*?['"`](.*?)['"`]/)?.[1] || ''
+ }
+
+ await fs.writeFile(
+ fileName,
+ `${content}\n` +
+ `
+ // alias __NEXT_TEST_MODE for next-deploy as "_" is not a valid
+ // env variable during deploy
+ if (process.env.NEXT_PRIVATE_TEST_MODE) {
+ process.env.__NEXT_TEST_MODE = process.env.NEXT_PRIVATE_TEST_MODE
+ }
+ `,
+ )
+ }
+ require('console').log(`Test directory created at ${this.testDir}`)
+ }
+
+ public async clean() {
+ if (this.childProcess) {
+ throw new Error(`stop() must be called before cleaning`)
+ }
+
+ const keptFiles = ['node_modules', 'package.json', 'yarn.lock']
+ for (const file of await fs.readdir(this.testDir)) {
+ if (!keptFiles.includes(file)) {
+ await fs.remove(path.join(this.testDir, file))
+ }
+ }
+ }
+
+ public async export(): Promise<{ exitCode?: number; cliOutput?: string }> {
+ return {}
+ }
+ public async setup(): Promise {}
+ public async start(useDirArg: boolean = false): Promise {}
+
+ public async destroy(): Promise {
+ if (this.isDestroyed) {
+ throw new Error(`next instance already destroyed`)
+ }
+ this.isDestroyed = true
+ this.emit('destroy', [])
+
+ if (!process.env.NEXT_TEST_SKIP_CLEANUP) {
+ await fs.remove(this.testDir)
+ }
+ require('console').log(`destroyed next instance`)
+ }
+
+ public get url() {
+ return this._url
+ }
+
+ public get appPort() {
+ return this._parsedUrl.port
+ }
+
+ public get buildId(): string {
+ return ''
+ }
+
+ public get cliOutput(): string {
+ return ''
+ }
+
+ // TODO: block these in deploy mode
+ public async readFile(filename: string) {
+ return fs.readFile(path.join(this.testDir, filename), 'utf8')
+ }
+ public async patchFile(filename: string, content: string) {
+ const outputPath = path.join(this.testDir, filename)
+ await fs.ensureDir(path.dirname(outputPath))
+ return fs.writeFile(outputPath, content)
+ }
+ public async renameFile(filename: string, newFilename: string) {
+ return fs.rename(path.join(this.testDir, filename), path.join(this.testDir, newFilename))
+ }
+ public async deleteFile(filename: string) {
+ return fs.remove(path.join(this.testDir, filename))
+ }
+
+ public on(event: Event, cb: (...args: any[]) => any) {
+ if (!this.events[event]) {
+ this.events[event] = new Set()
+ }
+ this.events[event].add(cb)
+ }
+
+ public off(event: Event, cb: (...args: any[]) => any) {
+ this.events[event]?.delete(cb)
+ }
+
+ protected emit(event: Event, args: any[]) {
+ this.events[event]?.forEach((cb) => {
+ cb(...args)
+ })
+ }
+}
diff --git a/test/e2e/next-test-lib/next-modes/next-deploy.ts b/test/e2e/next-test-lib/next-modes/next-deploy.ts
new file mode 100644
index 0000000000..2cfeab62a0
--- /dev/null
+++ b/test/e2e/next-test-lib/next-modes/next-deploy.ts
@@ -0,0 +1,102 @@
+import path from 'path'
+import execa from 'execa'
+import fs from 'fs-extra'
+import { platform } from 'os'
+import { NextInstance } from './base'
+
+export class NextDeployInstance extends NextInstance {
+ private _cliOutput: string
+ private _buildId: string
+
+ public get buildId() {
+ return this._buildId
+ }
+
+ public async setup() {
+ if (process.env.SITE_URL) {
+ process.env.NEXT_TEST_SKIP_CLEANUP = 'true'
+ console.log('Using SITE_URL', process.env.SITE_URL)
+ this._url = process.env.SITE_URL
+ this._parsedUrl = new URL(this._url)
+ this._buildId = 'build-id'
+ return
+ }
+
+ await super.createTestDir()
+ // We use yarn because it's better at handling local dependencies
+ await execa('yarn', [], {
+ cwd: this.testDir,
+ stdio: 'inherit',
+ })
+ // ensure Netlify CLI is installed
+ try {
+ const res = await execa('ntl', ['--version'])
+ console.log(`Using Netlify CLI version:`, res.stdout)
+ } catch (_) {
+ console.log(`Installing Netlify CLI`)
+ await execa('npm', ['i', '-g', 'netlify-cli@latest'], {
+ stdio: 'inherit',
+ })
+ }
+
+ const NETLIFY_SITE_ID = process.env.NETLIFY_SITE_ID || '1d5a5c76-d445-4ae5-b694-b0d3f2e2c395'
+
+ try {
+ const statRes = await execa('ntl', ['status', '--json'], { env: { NETLIFY_SITE_ID, NODE_ENV: 'production' } })
+ } catch (err) {
+ if (err.message.includes("You don't appear to be in a folder that is linked to a site")) {
+ throw new Error(`Site is not linked. Please set "NETLIFY_AUTH_TOKEN" and "NETLIFY_SITE_ID"`)
+ }
+ throw err
+ }
+
+ console.log(`Deploying project at ${this.testDir}`)
+
+ const deployRes = await execa('ntl', ['deploy', '--build', '--json'], {
+ cwd: this.testDir,
+ reject: false,
+ env: {
+ NETLIFY_SITE_ID,
+ NODE_ENV: 'production',
+ DISABLE_IPX: platform() === 'linux' ? undefined : '1',
+ },
+ })
+
+ if (deployRes.exitCode !== 0) {
+ throw new Error(`Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`)
+ }
+ try {
+ const data = JSON.parse(deployRes.stdout)
+ this._url = data.deploy_url
+ console.log(`Deployed to ${this._url}`)
+ this._parsedUrl = new URL(this._url)
+ } catch (err) {
+ console.error(err)
+ throw new Error(`Failed to parse deploy output: ${deployRes.stdout}`)
+ }
+ this._buildId = (
+ await fs.readFile(path.join(this.testDir, this.nextConfig?.distDir || '.next', 'BUILD_ID'), 'utf8')
+ ).trim()
+ }
+
+ public get cliOutput() {
+ return this._cliOutput || ''
+ }
+
+ public async start() {
+ // no-op as the deployment is created during setup()
+ }
+
+ public async patchFile(filename: string, content: string): Promise {
+ throw new Error('patchFile is not available in deploy test mode')
+ }
+ public async readFile(filename: string): Promise {
+ throw new Error('readFile is not available in deploy test mode')
+ }
+ public async deleteFile(filename: string): Promise {
+ throw new Error('deleteFile is not available in deploy test mode')
+ }
+ public async renameFile(filename: string, newFilename: string): Promise {
+ throw new Error('renameFile is not available in deploy test mode')
+ }
+}
diff --git a/test/e2e/next-test-lib/next-test-utils.js b/test/e2e/next-test-lib/next-test-utils.js
new file mode 100644
index 0000000000..f8e9935c24
--- /dev/null
+++ b/test/e2e/next-test-lib/next-test-utils.js
@@ -0,0 +1,625 @@
+// @ts-check
+import spawn from 'cross-spawn'
+import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
+import { writeFile } from 'fs-extra'
+import { fetch } from 'undici'
+import path from 'path'
+import qs from 'querystring'
+import { TextDecoderStream } from 'stream/web'
+
+export function initNextServerScript(scriptPath, successRegexp, env, failRegexp, opts) {
+ return new Promise((resolve, reject) => {
+ const instance = spawn(
+ 'node',
+ [
+ ...((opts && opts.nodeArgs) || []),
+ '-r',
+ require.resolve('./mocks-require-hook'),
+ '--no-deprecation',
+ scriptPath,
+ ],
+ {
+ env,
+ cwd: opts && opts.cwd,
+ },
+ )
+
+ function handleStdout(data) {
+ const message = data.toString()
+ if (successRegexp.test(message)) {
+ resolve(instance)
+ }
+ process.stdout.write(message)
+
+ if (opts && opts.onStdout) {
+ opts.onStdout(message.toString())
+ }
+ }
+
+ function handleStderr(data) {
+ const message = data.toString()
+ if (failRegexp && failRegexp.test(message)) {
+ instance.kill()
+ return reject(new Error('received failRegexp'))
+ }
+ process.stderr.write(message)
+
+ if (opts && opts.onStderr) {
+ opts.onStderr(message.toString())
+ }
+ }
+
+ instance.stdout.on('data', handleStdout)
+ instance.stderr.on('data', handleStderr)
+
+ instance.on('close', () => {
+ instance.stdout.removeListener('data', handleStdout)
+ instance.stderr.removeListener('data', handleStderr)
+ })
+
+ instance.on('error', (err) => {
+ reject(err)
+ })
+ })
+}
+
+/**
+ * @param {string | number} appPortOrUrl
+ * @param {string} [url]
+ * @param {string} [hostname]
+ * @returns
+ */
+export function getFullUrl(appPortOrUrl, url, hostname) {
+ let fullUrl =
+ typeof appPortOrUrl === 'string' && appPortOrUrl.startsWith('http')
+ ? appPortOrUrl
+ : `http://${hostname ? hostname : 'localhost'}:${appPortOrUrl}${url}`
+
+ if (typeof appPortOrUrl === 'string' && url) {
+ const parsedUrl = new URL(fullUrl)
+ const parsedPathQuery = new URL(url, fullUrl)
+
+ parsedUrl.hash = parsedPathQuery.hash
+ parsedUrl.search = parsedPathQuery.search
+ parsedUrl.pathname = parsedPathQuery.pathname
+
+ if (hostname && parsedUrl.hostname === 'localhost') {
+ parsedUrl.hostname = hostname
+ }
+ fullUrl = parsedUrl.toString()
+ }
+ return fullUrl
+}
+
+export function renderViaAPI(app, pathname, query) {
+ const url = `${pathname}${query ? `?${qs.stringify(query)}` : ''}`
+ return app.renderToHTML({ url }, {}, pathname, query)
+}
+
+async function processChunkedResponse(response) {
+ let text = ''
+ const stream = response.body.pipeThrough(new TextDecoderStream())
+
+ for await (const chunk of stream) {
+ text += chunk
+ }
+ return text
+}
+
+/**
+ * @param {string | number} appPort
+ * @param {string} pathname
+ * @param {Record | string | undefined} [query]
+ * @param {import("undici").RequestInit} [opts]
+ * @returns {Promise}
+ */
+export function renderViaHTTP(appPort, pathname, query, opts) {
+ return fetchViaHTTP(appPort, pathname, query, opts).then(processChunkedResponse)
+}
+
+/**
+ * @param {string | number} appPort
+ * @param {string} pathname
+ * @param {Record | string | undefined} [query]
+ * @param {import("undici").RequestInit} [opts]
+ * @returns {Promise}
+ */
+export function fetchViaHTTP(appPort, pathname, query, opts) {
+ const url = `${pathname}${typeof query === 'string' ? query : query ? `?${qs.stringify(query)}` : ''}`
+ return fetch(getFullUrl(appPort, url), opts)
+}
+
+export function runNextCommand(argv, options = {}) {
+ const nextDir = path.dirname(require.resolve('next/package'))
+ const nextBin = path.join(nextDir, 'dist/bin/next')
+ const cwd = options.cwd || nextDir
+ // Let Next.js decide the environment
+ const env = {
+ ...process.env,
+ NODE_ENV: '',
+ __NEXT_TEST_MODE: 'true',
+ NEXT_PRIVATE_OUTPUT_TRACE_ROOT: path.join(__dirname, '../../'),
+ ...options.env,
+ }
+
+ return new Promise((resolve, reject) => {
+ console.log(`Running command "next ${argv.join(' ')}"`)
+ const instance = spawn(
+ 'node',
+ [
+ ...(options.nodeArgs || []),
+ '-r',
+ require.resolve('./mocks-require-hook'),
+ '--no-deprecation',
+ nextBin,
+ ...argv,
+ ],
+ {
+ ...options.spawnOptions,
+ cwd,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+
+ if (typeof options.instance === 'function') {
+ options.instance(instance)
+ }
+
+ let mergedStdio = ''
+
+ let stderrOutput = ''
+ if (options.stderr) {
+ instance.stderr.on('data', function (chunk) {
+ mergedStdio += chunk
+ stderrOutput += chunk
+
+ if (options.stderr === 'log') {
+ console.log(chunk.toString())
+ }
+ })
+ } else {
+ instance.stderr.on('data', function (chunk) {
+ mergedStdio += chunk
+ })
+ }
+
+ let stdoutOutput = ''
+ if (options.stdout) {
+ instance.stdout.on('data', function (chunk) {
+ mergedStdio += chunk
+ stdoutOutput += chunk
+
+ if (options.stdout === 'log') {
+ console.log(chunk.toString())
+ }
+ })
+ } else {
+ instance.stdout.on('data', function (chunk) {
+ mergedStdio += chunk
+ })
+ }
+
+ instance.on('close', (code, signal) => {
+ if (!options.stderr && !options.stdout && !options.ignoreFail && code !== 0) {
+ return reject(new Error(`command failed with code ${code}\n${mergedStdio}`))
+ }
+
+ resolve({
+ code,
+ signal,
+ stdout: stdoutOutput,
+ stderr: stderrOutput,
+ })
+ })
+
+ instance.on('error', (err) => {
+ err.stdout = stdoutOutput
+ err.stderr = stderrOutput
+ reject(err)
+ })
+ })
+}
+
+export function runNextCommandDev(argv, stdOut, opts = {}) {
+ const nextDir = path.dirname(require.resolve('next/package'))
+ const nextBin = path.join(nextDir, 'dist/bin/next')
+ const cwd = opts.cwd || nextDir
+ const env = {
+ ...process.env,
+ NODE_ENV: undefined,
+ __NEXT_TEST_MODE: 'true',
+ ...opts.env,
+ }
+
+ const nodeArgs = opts.nodeArgs || []
+ return new Promise((resolve, reject) => {
+ const instance = spawn(
+ 'node',
+ [...nodeArgs, '-r', require.resolve('./mocks-require-hook'), '--no-deprecation', nextBin, ...argv],
+ {
+ cwd,
+ env,
+ },
+ )
+ let didResolve = false
+
+ function handleStdout(data) {
+ const message = data.toString()
+ const bootupMarkers = {
+ dev: /compiled .*successfully/i,
+ start: /started server/i,
+ }
+ if (
+ (opts.bootupMarker && opts.bootupMarker.test(message)) ||
+ bootupMarkers[opts.nextStart || stdOut ? 'start' : 'dev'].test(message)
+ ) {
+ if (!didResolve) {
+ didResolve = true
+ resolve(stdOut ? message : instance)
+ }
+ }
+
+ if (typeof opts.onStdout === 'function') {
+ opts.onStdout(message)
+ }
+
+ if (opts.stdout !== false) {
+ process.stdout.write(message)
+ }
+ }
+
+ function handleStderr(data) {
+ const message = data.toString()
+ if (typeof opts.onStderr === 'function') {
+ opts.onStderr(message)
+ }
+
+ if (opts.stderr !== false) {
+ process.stderr.write(message)
+ }
+ }
+
+ instance.stdout.on('data', handleStdout)
+ instance.stderr.on('data', handleStderr)
+
+ instance.on('close', () => {
+ instance.stdout.removeListener('data', handleStdout)
+ instance.stderr.removeListener('data', handleStderr)
+ if (!didResolve) {
+ didResolve = true
+ resolve()
+ }
+ })
+
+ instance.on('error', (err) => {
+ reject(err)
+ })
+ })
+}
+
+// Launch the app in dev mode.
+export function launchApp(dir, port, opts) {
+ return runNextCommandDev([dir, '-p', port], undefined, opts)
+}
+
+export function nextBuild(dir, args = [], opts = {}) {
+ return runNextCommand(['build', dir, ...args], opts)
+}
+
+export function nextExport(dir, { outdir }, opts = {}) {
+ return runNextCommand(['export', dir, '--outdir', outdir], opts)
+}
+
+export function nextExportDefault(dir, opts = {}) {
+ return runNextCommand(['export', dir], opts)
+}
+
+export function nextLint(dir, args = [], opts = {}) {
+ return runNextCommand(['lint', dir, ...args], opts)
+}
+
+export function nextStart(dir, port, opts = {}) {
+ return runNextCommandDev(['start', '-p', port, dir], undefined, {
+ ...opts,
+ nextStart: true,
+ })
+}
+
+export function buildTS(args = [], cwd, env = {}) {
+ cwd = cwd || path.dirname(require.resolve('next/package'))
+ env = { ...process.env, NODE_ENV: undefined, ...env }
+
+ return new Promise((resolve, reject) => {
+ const instance = spawn('node', ['--no-deprecation', require.resolve('typescript/lib/tsc'), ...args], { cwd, env })
+ let output = ''
+
+ const handleData = (chunk) => {
+ output += chunk.toString()
+ }
+
+ instance.stdout.on('data', handleData)
+ instance.stderr.on('data', handleData)
+
+ instance.on('exit', (code) => {
+ if (code) {
+ return reject(new Error('exited with code: ' + code + '\n' + output))
+ }
+ resolve()
+ })
+ })
+}
+
+export async function stopApp(server) {
+ if (server.__app) {
+ await server.__app.close()
+ }
+ await promiseCall(server, 'close')
+}
+
+export function promiseCall(obj, method, ...args) {
+ return new Promise((resolve, reject) => {
+ const newArgs = [
+ ...args,
+ function (err, res) {
+ if (err) return reject(err)
+ resolve(res)
+ },
+ ]
+
+ obj[method](...newArgs)
+ })
+}
+
+export function waitFor(millis) {
+ return new Promise((resolve) => setTimeout(resolve, millis))
+}
+
+// check for content in 1 second intervals timing out after
+// 30 seconds
+export async function check(contentFn, regex, hardError = true) {
+ let content
+ let lastErr
+
+ for (let tries = 0; tries < 30; tries++) {
+ try {
+ content = await contentFn()
+ if (typeof regex === 'string') {
+ if (regex === content) {
+ return true
+ }
+ } else if (regex.test(content)) {
+ // found the content
+ return true
+ }
+ await waitFor(1000)
+ } catch (err) {
+ await waitFor(1000)
+ lastErr = err
+ }
+ }
+ console.error('TIMED OUT CHECK: ', { regex, content, lastErr })
+
+ if (hardError) {
+ throw new Error('TIMED OUT: ' + regex + '\n\n' + content)
+ }
+ return false
+}
+
+export class File {
+ constructor(path) {
+ this.path = path
+ this.originalContent = existsSync(this.path) ? readFileSync(this.path, 'utf8') : null
+ }
+
+ write(content) {
+ if (!this.originalContent) {
+ this.originalContent = content
+ }
+ writeFileSync(this.path, content, 'utf8')
+ }
+
+ replace(pattern, newValue) {
+ const currentContent = readFileSync(this.path, 'utf8')
+ if (pattern instanceof RegExp) {
+ if (!pattern.test(currentContent)) {
+ throw new Error(`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`)
+ }
+ } else if (typeof pattern === 'string') {
+ if (!currentContent.includes(pattern)) {
+ throw new Error(`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`)
+ }
+ } else {
+ throw new Error(`Unknown replacement attempt type: ${pattern}`)
+ }
+
+ const newContent = currentContent.replace(pattern, newValue)
+ this.write(newContent)
+ }
+
+ delete() {
+ unlinkSync(this.path)
+ }
+
+ restore() {
+ this.write(this.originalContent)
+ }
+}
+
+export async function evaluate(browser, input) {
+ if (typeof input === 'function') {
+ const result = await browser.eval(input)
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ return result
+ } else {
+ throw new Error(`You must pass a function to be evaluated in the browser.`)
+ }
+}
+
+export async function retry(fn, duration = 3000, interval = 500, description) {
+ if (duration % interval !== 0) {
+ throw new Error(
+ `invalid duration ${duration} and interval ${interval} mix, duration must be evenly divisible by interval`,
+ )
+ }
+
+ for (let i = duration; i >= 0; i -= interval) {
+ try {
+ return await fn()
+ } catch (err) {
+ if (i === 0) {
+ console.error(`Failed to retry${description ? ` ${description}` : ''} within ${duration}ms`)
+ throw err
+ }
+ console.warn(`Retrying${description ? ` ${description}` : ''} in ${interval}ms`)
+ await waitFor(interval)
+ }
+ }
+}
+
+export async function hasRedbox(browser, expected = true) {
+ for (let i = 0; i < 30; i++) {
+ const result = await evaluate(browser, () => {
+ return Boolean(
+ [].slice
+ .call(document.querySelectorAll('nextjs-portal'))
+ .find((p) =>
+ p.shadowRoot.querySelector('#nextjs__container_errors_label, #nextjs__container_build_error_label'),
+ ),
+ )
+ })
+
+ if (result === expected) {
+ return result
+ }
+ await waitFor(1000)
+ }
+ return false
+}
+
+export async function getRedboxHeader(browser) {
+ return retry(
+ () =>
+ evaluate(browser, () => {
+ const portal = [].slice
+ .call(document.querySelectorAll('nextjs-portal'))
+ .find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header'))
+ const root = portal.shadowRoot
+ return root
+ .querySelector('[data-nextjs-dialog-header]')
+ .innerText.replace(/__WEBPACK_DEFAULT_EXPORT__/, 'Unknown')
+ }),
+ 3000,
+ 500,
+ 'getRedboxHeader',
+ )
+}
+
+export async function getRedboxSource(browser) {
+ return retry(
+ () =>
+ evaluate(browser, () => {
+ const portal = [].slice
+ .call(document.querySelectorAll('nextjs-portal'))
+ .find((p) =>
+ p.shadowRoot.querySelector('#nextjs__container_errors_label, #nextjs__container_build_error_label'),
+ )
+ const root = portal.shadowRoot
+ return root
+ .querySelector('[data-nextjs-codeframe], [data-nextjs-terminal]')
+ .innerText.replace(/__WEBPACK_DEFAULT_EXPORT__/, 'Unknown')
+ }),
+ 3000,
+ 500,
+ 'getRedboxSource',
+ )
+}
+
+export async function getRedboxDescription(browser) {
+ return retry(
+ () =>
+ evaluate(browser, () => {
+ const portal = [].slice
+ .call(document.querySelectorAll('nextjs-portal'))
+ .find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
+ const root = portal.shadowRoot
+ return root
+ .querySelector('#nextjs__container_errors_desc')
+ .innerText.replace(/__WEBPACK_DEFAULT_EXPORT__/, 'Unknown')
+ }),
+ 3000,
+ 500,
+ 'getRedboxDescription',
+ )
+}
+
+export function getBrowserBodyText(browser) {
+ return browser.eval('document.getElementsByTagName("body")[0].innerText')
+}
+
+export function normalizeRegEx(src) {
+ return new RegExp(src).source.replace(/\^\//g, '^\\/')
+}
+
+function readJson(path) {
+ return JSON.parse(readFileSync(path, 'utf8'))
+}
+
+export function getBuildManifest(dir) {
+ return readJson(path.join(dir, '.next/build-manifest.json'))
+}
+
+export function getPageFileFromBuildManifest(dir, page) {
+ const buildManifest = getBuildManifest(dir)
+ const pageFiles = buildManifest.pages[page]
+ if (!pageFiles) {
+ throw new Error(`No files for page ${page}`)
+ }
+
+ const pageFile = pageFiles.find(
+ (file) => file.endsWith('.js') && file.includes(`pages${page === '' ? '/index' : page}`),
+ )
+ if (!pageFile) {
+ throw new Error(`No page file for page ${page}`)
+ }
+
+ return pageFile
+}
+
+export function readNextBuildClientPageFile(appDir, page) {
+ const pageFile = getPageFileFromBuildManifest(appDir, page)
+ return readFileSync(path.join(appDir, '.next', pageFile), 'utf8')
+}
+
+export function getPagesManifest(dir) {
+ const serverFile = path.join(dir, '.next/server/pages-manifest.json')
+
+ if (existsSync(serverFile)) {
+ return readJson(serverFile)
+ }
+ return readJson(path.join(dir, '.next/serverless/pages-manifest.json'))
+}
+
+export function updatePagesManifest(dir, content) {
+ const serverFile = path.join(dir, '.next/server/pages-manifest.json')
+
+ if (existsSync(serverFile)) {
+ return writeFile(serverFile, content)
+ }
+ return writeFile(path.join(dir, '.next/serverless/pages-manifest.json'), content)
+}
+
+export function getPageFileFromPagesManifest(dir, page) {
+ const pagesManifest = getPagesManifest(dir)
+ const pageFile = pagesManifest[page]
+ if (!pageFile) {
+ throw new Error(`No file for page ${page}`)
+ }
+
+ return pageFile
+}
+
+export function readNextBuildServerPageFile(appDir, page) {
+ const pageFile = getPageFileFromPagesManifest(appDir, page)
+ return readFileSync(path.join(appDir, '.next', 'server', pageFile), 'utf8')
+}
diff --git a/test/e2e/next-test-lib/next-webdriver.ts b/test/e2e/next-test-lib/next-webdriver.ts
new file mode 100644
index 0000000000..e7966d2857
--- /dev/null
+++ b/test/e2e/next-test-lib/next-webdriver.ts
@@ -0,0 +1,112 @@
+import { getFullUrl } from './next-test-utils'
+import { BrowserInterface } from './browsers/base'
+;(global as any).browserName = process.env.BROWSER_NAME || 'chrome'
+
+let browserQuit: () => Promise
+
+if (typeof afterAll === 'function') {
+ afterAll(async () => {
+ if (browserQuit) {
+ await browserQuit()
+ }
+ })
+}
+
+/**
+ *
+ * @param appPortOrUrl can either be the port or the full URL
+ * @param url the path/query to append when using appPort
+ * @param options.waitHydration whether to wait for react hydration to finish
+ * @param options.retryWaitHydration allow retrying hydration wait if reload occurs
+ * @param options.disableCache disable cache for page load
+ * @param options.beforePageLoad the callback receiving page instance before loading page
+ * @returns thenable browser instance
+ */
+export default async function webdriver(
+ appPortOrUrl: string | number,
+ url: string,
+ options?: {
+ waitHydration?: boolean
+ retryWaitHydration?: boolean
+ disableCache?: boolean
+ beforePageLoad?: (page: any) => void
+ locale?: string
+ },
+): Promise {
+ let CurrentInterface: typeof BrowserInterface
+
+ const defaultOptions = {
+ waitHydration: true,
+ retryWaitHydration: false,
+ disableCache: false,
+ }
+ options = Object.assign(defaultOptions, options)
+ const { waitHydration, retryWaitHydration, disableCache, beforePageLoad, locale } = options
+
+ const { Playwright, quit } = await import('./browsers/playwright')
+ CurrentInterface = Playwright
+ browserQuit = quit
+
+ const browser = new CurrentInterface()
+ const browserName = process.env.BROWSER_NAME || 'chrome'
+ await browser.setup(browserName, locale)
+ ;(global as any).browserName = browserName
+
+ const fullUrl = getFullUrl(appPortOrUrl, url, 'localhost')
+
+ console.log(`\n> Loading browser with ${fullUrl}\n`)
+
+ await browser.loadPage(fullUrl, { disableCache, beforePageLoad })
+ console.log(`\n> Loaded browser with ${fullUrl}\n`)
+
+ // Wait for application to hydrate
+ if (waitHydration) {
+ console.log(`\n> Waiting hydration for ${fullUrl}\n`)
+
+ const checkHydrated = async () => {
+ await browser.evalAsync(function () {
+ var callback = arguments[arguments.length - 1]
+
+ // if it's not a Next.js app return
+ if (
+ document.documentElement.innerHTML.indexOf('__NEXT_DATA__') === -1 &&
+ // @ts-ignore next exists on window if it's a Next.js page.
+ typeof ((window as any).next && (window as any).next.version) === 'undefined'
+ ) {
+ console.log('Not a next.js page, resolving hydrate check')
+ callback()
+ }
+
+ // TODO: should we also ensure router.isReady is true
+ // by default before resolving?
+ if ((window as any).__NEXT_HYDRATED) {
+ console.log('Next.js page already hydrated')
+ callback()
+ } else {
+ var timeout = setTimeout(callback, 10 * 1000)
+ ;(window as any).__NEXT_HYDRATED_CB = function () {
+ clearTimeout(timeout)
+ console.log('Next.js hydrate callback fired')
+ callback()
+ }
+ }
+ })
+ }
+
+ try {
+ await checkHydrated()
+ } catch (err) {
+ if (retryWaitHydration) {
+ // re-try in case the page reloaded during check
+ await new Promise((resolve) => setTimeout(resolve, 2000))
+ await checkHydrated()
+ } else {
+ console.error('failed to check hydration')
+ throw err
+ }
+ }
+
+ console.log(`\n> Hydration complete for ${fullUrl}\n`)
+ }
+ return browser
+}
diff --git a/test/e2e/next-test-lib/react-channel-require-hook.js b/test/e2e/next-test-lib/react-channel-require-hook.js
new file mode 100644
index 0000000000..b4a1ab01e2
--- /dev/null
+++ b/test/e2e/next-test-lib/react-channel-require-hook.js
@@ -0,0 +1,26 @@
+const mod = require('module')
+
+// The value will be '17' or 'exp' to alias the actual react channel
+const reactVersion = process.env.__NEXT_REACT_CHANNEL
+
+const reactDir = `react-${reactVersion}`
+const reactDomDir = `react-dom-${reactVersion}`
+
+const hookPropertyMap = new Map([
+ ['react', reactDir],
+ ['react/package.json', `${reactDir}/package.json`],
+ ['react/jsx-runtime', `${reactDir}/jsx-runtime`],
+ ['react/jsx-dev-runtime', `${reactDir}/jsx-dev-runtime`],
+ ['react-dom', `${reactDomDir}`],
+ ['react-dom/package.json', `${reactDomDir}/package.json`],
+ ['react-dom/client', `${reactDomDir}/client`],
+ ['react-dom/server', `${reactDomDir}/server`],
+ ['react-dom/server.browser', `${reactDomDir}/server.browser`],
+])
+
+const resolveFilename = mod._resolveFilename
+mod._resolveFilename = function (request, parent, isMain, options) {
+ const hookResolved = hookPropertyMap.get(request)
+ if (hookResolved) request = hookResolved
+ return resolveFilename.call(mod, request, parent, isMain, options)
+}
diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json
new file mode 100644
index 0000000000..a510070e1f
--- /dev/null
+++ b/test/e2e/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "e2e-utils": ["next-test-lib/e2e-utils.ts"],
+ "test/lib/next-modes/base": ["next-test-lib/next-modes/base.ts"],
+ "next-test-utils": ["next-test-lib/next-test-utils.js"],
+ "next-webdriver": ["next-test-lib/next-webdriver.ts"]
+ }
+ }
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index a68cb8ff2b..43b471ca49 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -69,6 +69,7 @@
// The following hack is to prevent TS using the chai types instead of jest types.
// Source: https://github.com/cypress-io/cypress/issues/1087#issuecomment-552951441
"include": [
+ "test",
"node_modules/cypress",
],
"exclude": [