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": [